feat: Enhanced Plugin Management UI with Dependency Visualization

NEW FEATURES:

1. Dependency Indicators:
   - 🔗 (cyan) - Plugin has dependencies (hover for list)
   - ⚠️ (yellow) - Plugin is required by other enabled plugins
   - 🔄 (orange) - Auto-enabled due to dependency

2. Legend Bar:
   Shows what each indicator means at the top of the plugins tab

3. Dependency Tooltips:
   - 🔗 Shows: 'This plugin requires: X, Y, Z. These will be auto-enabled.'
   - ⚠️ Shows: 'Required by enabled plugins: A, B. Disable those first.'
   - 🔄 Shows: 'Auto-enabled by: PluginName'

4. Enable with Dependencies:
   When enabling a plugin with unmet dependencies:
   - Shows confirmation dialog listing all plugins to be enabled
   - User can cancel before enabling
   - Dependencies are auto-enabled on confirmation

5. Disable Protection:
   When trying to disable a plugin that others depend on:
   - Shows warning dialog listing dependent plugins
   - Prevents accidental breaking of dependencies
   - User must disable dependents first

6. Dependency Report Dialog:
   - New '📋 Dependency Report' button
   - Shows HTML report with:
     * Summary stats (total/enabled plugins)
     * Plugins with dependencies list
     * Plugins required by others list
     * Full dependency chains

7. Enable/Disable All with Ordering:
   - Dependencies are enabled first (topological sort)
   - Dependents are disabled first (reverse order)
   - Prevents enable/disable failures due to ordering

8. Auto-refresh UI:
   After enabling/disabling, plugin list refreshes to show:
   - Updated auto-enabled status
   - Updated dependency indicators
   - Updated checkbox states

VISUAL IMPROVEMENTS:
- Better spacing and layout
- Color-coded indicators
- Clear visual hierarchy
- Informative tooltips throughout

This makes plugin management much more intuitive and prevents
common mistakes like accidentally breaking dependencies.
This commit is contained in:
LemonNexus 2026-02-15 01:25:49 +00:00
parent 194cda2c62
commit b63763b528
1 changed files with 485 additions and 48 deletions

View File

@ -184,16 +184,41 @@ class SettingsPlugin(BasePlugin):
return tab return tab
def _create_plugins_tab(self): def _create_plugins_tab(self):
"""Create plugins management tab - enable/disable plugins.""" """Create plugins management tab with dependency visualization."""
tab = QWidget() tab = QWidget()
layout = QVBoxLayout(tab) layout = QVBoxLayout(tab)
layout.setSpacing(15) layout.setSpacing(15)
# Info label # Info label
info = QLabel("Check plugins to enable them. Uncheck to disable. Changes take effect immediately.") info = QLabel("Manage plugins. Hover over dependency icons to see requirements. Changes take effect immediately.")
info.setStyleSheet("color: rgba(255,255,255,150);") info.setStyleSheet("color: rgba(255,255,255,150);")
layout.addWidget(info) layout.addWidget(info)
# Dependency legend
legend_layout = QHBoxLayout()
legend_layout.addWidget(QLabel("Legend:"))
# Required by others indicator
req_label = QLabel("⚠️ Required")
req_label.setStyleSheet("color: #ffd93d; font-size: 11px;")
req_label.setToolTip("This plugin is required by other enabled plugins")
legend_layout.addWidget(req_label)
# Has dependencies indicator
dep_label = QLabel("🔗 Has deps")
dep_label.setStyleSheet("color: #4ecdc4; font-size: 11px;")
dep_label.setToolTip("This plugin requires other plugins to function")
legend_layout.addWidget(dep_label)
# Auto-enabled indicator
auto_label = QLabel("🔄 Auto")
auto_label.setStyleSheet("color: #ff8c42; font-size: 11px;")
auto_label.setToolTip("Auto-enabled due to dependency")
legend_layout.addWidget(auto_label)
legend_layout.addStretch()
layout.addLayout(legend_layout)
# Scroll area for plugin list # Scroll area for plugin list
scroll = QScrollArea() scroll = QScrollArea()
scroll.setWidgetResizable(True) scroll.setWidgetResizable(True)
@ -206,51 +231,23 @@ class SettingsPlugin(BasePlugin):
plugins_layout.setAlignment(Qt.AlignmentFlag.AlignTop) plugins_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.plugin_checkboxes = {} self.plugin_checkboxes = {}
self.plugin_dependency_labels = {}
self.plugin_rows = {}
# Get all discovered plugins from plugin manager # Get all discovered plugins from plugin manager
if hasattr(self.overlay, 'plugin_manager'): if hasattr(self.overlay, 'plugin_manager'):
plugin_manager = self.overlay.plugin_manager plugin_manager = self.overlay.plugin_manager
all_plugins = plugin_manager.get_all_discovered_plugins() all_plugins = plugin_manager.get_all_discovered_plugins()
# Build dependency maps
self._build_dependency_maps(all_plugins)
# Sort by name # Sort by name
sorted_plugins = sorted(all_plugins.items(), key=lambda x: x[1].name) sorted_plugins = sorted(all_plugins.items(), key=lambda x: x[1].name)
for plugin_id, plugin_class in sorted_plugins: for plugin_id, plugin_class in sorted_plugins:
row = QHBoxLayout() plugin_row = self._create_plugin_row(plugin_id, plugin_class, plugin_manager)
plugins_layout.addLayout(plugin_row)
# Checkbox
cb = QCheckBox(plugin_class.name)
cb.setChecked(plugin_manager.is_plugin_enabled(plugin_id))
cb.setStyleSheet("""
QCheckBox {
color: white;
spacing: 8px;
}
QCheckBox::indicator {
width: 18px;
height: 18px;
}
""")
# Connect to enable/disable
cb.stateChanged.connect(
lambda state, pid=plugin_id: self._toggle_plugin(pid, state == Qt.CheckState.Checked.value)
)
self.plugin_checkboxes[plugin_id] = cb
row.addWidget(cb)
# Version
version_label = QLabel(f"v{plugin_class.version}")
version_label.setStyleSheet("color: rgba(255,255,255,100); font-size: 11px;")
row.addWidget(version_label)
# Description
desc_label = QLabel(f"- {plugin_class.description}")
desc_label.setStyleSheet("color: rgba(255,255,255,100); font-size: 11px;")
desc_label.setWordWrap(True)
row.addWidget(desc_label, 1)
row.addStretch()
plugins_layout.addLayout(row)
# Separator # Separator
sep = QFrame() sep = QFrame()
@ -292,11 +289,234 @@ class SettingsPlugin(BasePlugin):
disable_all_btn.clicked.connect(self._disable_all_plugins) disable_all_btn.clicked.connect(self._disable_all_plugins)
btn_layout.addWidget(disable_all_btn) btn_layout.addWidget(disable_all_btn)
# Dependency info button
deps_info_btn = QPushButton("📋 Dependency Report")
deps_info_btn.setStyleSheet("""
QPushButton {
background-color: #4a9eff;
color: white;
padding: 8px 16px;
border: none;
border-radius: 4px;
}
""")
deps_info_btn.clicked.connect(self._show_dependency_report)
btn_layout.addWidget(deps_info_btn)
btn_layout.addStretch() btn_layout.addStretch()
layout.addLayout(btn_layout) layout.addLayout(btn_layout)
return tab return tab
def _build_dependency_maps(self, all_plugins):
"""Build maps of plugin dependencies."""
self.plugin_deps = {} # plugin_id -> list of plugin_ids it depends on
self.plugin_dependents = {} # plugin_id -> list of plugin_ids that depend on it
for plugin_id, plugin_class in all_plugins.items():
deps = getattr(plugin_class, 'dependencies', {})
plugin_deps_list = deps.get('plugins', [])
self.plugin_deps[plugin_id] = plugin_deps_list
# Build reverse map
for dep_id in plugin_deps_list:
if dep_id not in self.plugin_dependents:
self.plugin_dependents[dep_id] = []
self.plugin_dependents[dep_id].append(plugin_id)
def _create_plugin_row(self, plugin_id, plugin_class, plugin_manager):
"""Create a plugin row with dependency indicators."""
row = QHBoxLayout()
row.setSpacing(10)
# Checkbox
cb = QCheckBox(plugin_class.name)
is_enabled = plugin_manager.is_plugin_enabled(plugin_id)
is_auto_enabled = plugin_manager.is_auto_enabled(plugin_id) if hasattr(plugin_manager, 'is_auto_enabled') else False
cb.setChecked(is_enabled)
cb.setStyleSheet("""
QCheckBox {
color: white;
spacing: 8px;
}
QCheckBox::indicator {
width: 18px;
height: 18px;
}
QCheckBox::indicator:disabled {
background-color: #ff8c42;
}
""")
# Disable checkbox if auto-enabled
if is_auto_enabled:
cb.setEnabled(False)
cb.setText(f"{plugin_class.name} (auto)")
# Connect to enable/disable
cb.stateChanged.connect(
lambda state, pid=plugin_id: self._toggle_plugin(pid, state == Qt.CheckState.Checked.value)
)
self.plugin_checkboxes[plugin_id] = cb
row.addWidget(cb)
# Dependency indicators
indicators_layout = QHBoxLayout()
indicators_layout.setSpacing(4)
# Check if this plugin has dependencies
deps = self.plugin_deps.get(plugin_id, [])
if deps:
deps_btn = QPushButton("🔗")
deps_btn.setFixedSize(24, 24)
deps_btn.setStyleSheet("""
QPushButton {
background-color: transparent;
color: #4ecdc4;
border: none;
font-size: 12px;
}
QPushButton:hover {
background-color: rgba(78, 205, 196, 30);
border-radius: 4px;
}
""")
deps_btn.setToolTip(self._format_dependencies_tooltip(plugin_id, deps))
indicators_layout.addWidget(deps_btn)
# Check if other plugins depend on this one
dependents = self.plugin_dependents.get(plugin_id, [])
enabled_dependents = [d for d in dependents if plugin_manager.is_plugin_enabled(d)]
if enabled_dependents:
req_btn = QPushButton("⚠️")
req_btn.setFixedSize(24, 24)
req_btn.setStyleSheet("""
QPushButton {
background-color: transparent;
color: #ffd93d;
border: none;
font-size: 12px;
}
QPushButton:hover {
background-color: rgba(255, 217, 61, 30);
border-radius: 4px;
}
""")
req_btn.setToolTip(self._format_dependents_tooltip(plugin_id, enabled_dependents))
indicators_layout.addWidget(req_btn)
# Check if auto-enabled
if is_auto_enabled:
auto_btn = QPushButton("🔄")
auto_btn.setFixedSize(24, 24)
auto_btn.setStyleSheet("""
QPushButton {
background-color: transparent;
color: #ff8c42;
border: none;
font-size: 12px;
}
QPushButton:hover {
background-color: rgba(255, 140, 66, 30);
border-radius: 4px;
}
""")
# Find what enabled this plugin
enabler = self._find_enabler(plugin_id, plugin_manager)
auto_btn.setToolTip(f"Auto-enabled by: {enabler or 'dependency resolution'}")
indicators_layout.addWidget(auto_btn)
row.addLayout(indicators_layout)
# Version
version_label = QLabel(f"v{plugin_class.version}")
version_label.setStyleSheet("color: rgba(255,255,255,100); font-size: 11px;")
row.addWidget(version_label)
# Description
desc_label = QLabel(f"- {plugin_class.description}")
desc_label.setStyleSheet("color: rgba(255,255,255,100); font-size: 11px;")
desc_label.setWordWrap(True)
row.addWidget(desc_label, 1)
row.addStretch()
# Store row reference for updates
self.plugin_rows[plugin_id] = row
return row
def _format_dependencies_tooltip(self, plugin_id, deps):
"""Format tooltip for dependencies."""
lines = ["This plugin requires:"]
for dep_id in deps:
dep_name = dep_id.split('.')[-1].replace('_', ' ').title()
lines.append(f"{dep_name}")
lines.append("")
lines.append("These will be auto-enabled when you enable this plugin.")
return "\n".join(lines)
def _format_dependents_tooltip(self, plugin_id, dependents):
"""Format tooltip for plugins that depend on this one."""
lines = ["Required by enabled plugins:"]
for dep_id in dependents:
dep_name = dep_id.split('.')[-1].replace('_', ' ').title()
lines.append(f"{dep_name}")
lines.append("")
lines.append("Disable these first to disable this plugin.")
return "\n".join(lines)
def _find_enabler(self, plugin_id, plugin_manager):
"""Find which plugin auto-enabled this one."""
# Check all enabled plugins to see which one depends on this
for other_id, other_class in plugin_manager.get_all_discovered_plugins().items():
if plugin_manager.is_plugin_enabled(other_id):
deps = getattr(other_class, 'dependencies', {}).get('plugins', [])
if plugin_id in deps:
return other_class.name
return None
def _show_dependency_report(self):
"""Show a dialog with full dependency report."""
from PyQt6.QtWidgets import QDialog, QTextEdit, QVBoxLayout, QPushButton
dialog = QDialog()
dialog.setWindowTitle("Plugin Dependency Report")
dialog.setMinimumSize(600, 400)
dialog.setStyleSheet("""
QDialog {
background-color: #1a1f2e;
}
QTextEdit {
background-color: #232837;
color: white;
border: 1px solid rgba(100, 110, 130, 80);
padding: 10px;
}
QPushButton {
background-color: #4a9eff;
color: white;
padding: 8px 16px;
border: none;
border-radius: 4px;
}
""")
layout = QVBoxLayout(dialog)
text_edit = QTextEdit()
text_edit.setReadOnly(True)
text_edit.setHtml(self._generate_dependency_report())
layout.addWidget(text_edit)
close_btn = QPushButton("Close")
close_btn.clicked.connect(dialog.close)
layout.addWidget(close_btn)
dialog.exec()
def _create_hotkeys_tab(self): def _create_hotkeys_tab(self):
"""Create hotkeys configuration tab.""" """Create hotkeys configuration tab."""
tab = QWidget() tab = QWidget()
@ -520,42 +740,259 @@ class SettingsPlugin(BasePlugin):
pass pass
def _toggle_plugin(self, plugin_id: str, enable: bool): def _toggle_plugin(self, plugin_id: str, enable: bool):
"""Enable or disable a plugin.""" """Enable or disable a plugin with dependency handling."""
if not hasattr(self.overlay, 'plugin_manager'): if not hasattr(self.overlay, 'plugin_manager'):
return return
plugin_manager = self.overlay.plugin_manager plugin_manager = self.overlay.plugin_manager
if enable: if enable:
# Get dependencies that will be auto-enabled
deps_to_enable = self._get_missing_dependencies(plugin_id, plugin_manager)
if deps_to_enable:
# Show confirmation dialog
from PyQt6.QtWidgets import QMessageBox
dep_names = [pid.split('.')[-1].replace('_', ' ').title() for pid in deps_to_enable]
msg = f"Enabling this plugin will also enable:\n\n"
msg += "\n".join(f"{name}" for name in dep_names)
msg += "\n\nContinue?"
reply = QMessageBox.question(
None, "Enable Dependencies", msg,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply != QMessageBox.StandardButton.Yes:
# Uncheck the box
self.plugin_checkboxes[plugin_id].setChecked(False)
return
success = plugin_manager.enable_plugin(plugin_id) success = plugin_manager.enable_plugin(plugin_id)
if success: if success:
print(f"[Settings] Enabled plugin: {plugin_id}") print(f"[Settings] Enabled plugin: {plugin_id}")
# Refresh UI to show auto-enabled plugins
self._refresh_plugin_list()
else: else:
print(f"[Settings] Failed to enable plugin: {plugin_id}") print(f"[Settings] Failed to enable plugin: {plugin_id}")
else: else:
# Check if other enabled plugins depend on this one
dependents = self.plugin_dependents.get(plugin_id, [])
enabled_dependents = [d for d in dependents if plugin_manager.is_plugin_enabled(d)]
if enabled_dependents:
# Show warning
from PyQt6.QtWidgets import QMessageBox
dep_names = [pid.split('.')[-1].replace('_', ' ').title() for pid in enabled_dependents]
msg = f"Cannot disable: This plugin is required by:\n\n"
msg += "\n".join(f"{name}" for name in dep_names)
msg += "\n\nDisable those plugins first."
QMessageBox.warning(None, "Dependency Warning", msg)
# Recheck the box
self.plugin_checkboxes[plugin_id].setChecked(True)
return
success = plugin_manager.disable_plugin(plugin_id) success = plugin_manager.disable_plugin(plugin_id)
if success: if success:
print(f"[Settings] Disabled plugin: {plugin_id}") print(f"[Settings] Disabled plugin: {plugin_id}")
self._refresh_plugin_list()
# Notify user a restart might be needed for some changes def _get_missing_dependencies(self, plugin_id: str, plugin_manager) -> list:
# But we try to apply immediately """Get list of dependencies that need to be enabled."""
deps = self.plugin_deps.get(plugin_id, [])
missing = []
for dep_id in deps:
if not plugin_manager.is_plugin_enabled(dep_id):
missing.append(dep_id)
return missing
def _refresh_plugin_list(self):
"""Refresh the plugin list UI."""
if not hasattr(self.overlay, 'plugin_manager'):
return
plugin_manager = self.overlay.plugin_manager
for plugin_id, cb in self.plugin_checkboxes.items():
is_enabled = plugin_manager.is_plugin_enabled(plugin_id)
is_auto_enabled = plugin_manager.is_auto_enabled(plugin_id) if hasattr(plugin_manager, 'is_auto_enabled') else False
cb.setChecked(is_enabled)
if is_auto_enabled:
cb.setEnabled(False)
# Update text to show auto status
plugin_class = plugin_manager.get_all_discovered_plugins().get(plugin_id)
if plugin_class:
cb.setText(f"{plugin_class.name} (auto)")
else:
cb.setEnabled(True)
plugin_class = plugin_manager.get_all_discovered_plugins().get(plugin_id)
if plugin_class:
cb.setText(plugin_class.name)
def _generate_dependency_report(self) -> str:
"""Generate HTML dependency report."""
if not hasattr(self.overlay, 'plugin_manager'):
return "<p>No plugin manager available</p>"
plugin_manager = self.overlay.plugin_manager
all_plugins = plugin_manager.get_all_discovered_plugins()
html = ["<html><body style='font-family: Arial; color: white;'>"]
html.append("<h2>📋 Plugin Dependency Report</h2>")
html.append("<hr>")
# Summary section
total = len(all_plugins)
enabled = sum(1 for pid in all_plugins if plugin_manager.is_plugin_enabled(pid))
html.append(f"<p><b>Total Plugins:</b> {total} | <b>Enabled:</b> {enabled}</p>")
html.append("<hr>")
# Plugins with dependencies
html.append("<h3>🔗 Plugins with Dependencies</h3>")
html.append("<ul>")
for plugin_id, deps in sorted(self.plugin_deps.items()):
if deps:
plugin_class = all_plugins.get(plugin_id)
if plugin_class:
name = plugin_class.name
dep_names = [d.split('.')[-1].replace('_', ' ').title() for d in deps]
html.append(f"<li><b>{name}</b> requires: {', '.join(dep_names)}</li>")
html.append("</ul>")
html.append("<hr>")
# Plugins required by others
html.append("<h3>⚠️ Plugins Required by Others</h3>")
html.append("<ul>")
for plugin_id, dependents in sorted(self.plugin_dependents.items()):
if dependents:
plugin_class = all_plugins.get(plugin_id)
if plugin_class:
name = plugin_class.name
dep_names = [d.split('.')[-1].replace('_', ' ').title() for d in dependents]
html.append(f"<li><b>{name}</b> is required by: {', '.join(dep_names)}</li>")
html.append("</ul>")
html.append("<hr>")
# Dependency chain visualization
html.append("<h3>🔄 Dependency Chains</h3>")
html.append("<ul>")
for plugin_id, plugin_class in sorted(all_plugins.items(), key=lambda x: x[1].name):
chain = self._get_dependency_chain(plugin_id)
if len(chain) > 1:
chain_names = [all_plugins.get(pid, type('obj', (object,), {'name': pid})) .name for pid in chain]
html.append(f"<li>{''.join(chain_names)}</li>")
html.append("</ul>")
html.append("</body></html>")
return "\n".join(html)
def _get_dependency_chain(self, plugin_id: str, visited=None) -> list:
"""Get the dependency chain for a plugin."""
if visited is None:
visited = set()
if plugin_id in visited:
return [plugin_id] # Circular dependency
visited.add(plugin_id)
chain = [plugin_id]
# Get what this plugin depends on
deps = self.plugin_deps.get(plugin_id, [])
for dep_id in deps:
if dep_id not in visited:
chain.extend(self._get_dependency_chain(dep_id, visited))
return chain
def _enable_all_plugins(self): def _enable_all_plugins(self):
"""Enable all plugins.""" """Enable all plugins with dependency resolution."""
if not hasattr(self.overlay, 'plugin_manager'): if not hasattr(self.overlay, 'plugin_manager'):
return return
plugin_manager = self.overlay.plugin_manager plugin_manager = self.overlay.plugin_manager
for plugin_id, cb in self.plugin_checkboxes.items(): all_plugins = plugin_manager.get_all_discovered_plugins()
# Sort plugins so dependencies are enabled first
sorted_plugins = self._sort_plugins_by_dependencies(all_plugins)
enabled_count = 0
for plugin_id in sorted_plugins:
cb = self.plugin_checkboxes.get(plugin_id)
if cb:
cb.setChecked(True) cb.setChecked(True)
plugin_manager.enable_plugin(plugin_id) success = plugin_manager.enable_plugin(plugin_id)
if success:
enabled_count += 1
self._refresh_plugin_list()
print(f"[Settings] Enabled {enabled_count} plugins")
def _sort_plugins_by_dependencies(self, all_plugins: dict) -> list:
"""Sort plugins so dependencies come before dependents."""
plugin_ids = list(all_plugins.keys())
# Build dependency graph
graph = {pid: set(self.plugin_deps.get(pid, [])) for pid in plugin_ids}
# Topological sort
sorted_list = []
visited = set()
temp_mark = set()
def visit(pid):
if pid in temp_mark:
return # Circular dependency, skip
if pid in visited:
return
temp_mark.add(pid)
for dep in graph.get(pid, set()):
if dep in graph:
visit(dep)
temp_mark.remove(pid)
visited.add(pid)
sorted_list.append(pid)
for pid in plugin_ids:
visit(pid)
return sorted_list
def _disable_all_plugins(self): def _disable_all_plugins(self):
"""Disable all plugins.""" """Disable all plugins in reverse dependency order."""
if not hasattr(self.overlay, 'plugin_manager'): if not hasattr(self.overlay, 'plugin_manager'):
return return
plugin_manager = self.overlay.plugin_manager plugin_manager = self.overlay.plugin_manager
for plugin_id, cb in self.plugin_checkboxes.items(): all_plugins = plugin_manager.get_all_discovered_plugins()
# Sort so dependents are disabled first (reverse of enable order)
sorted_plugins = self._sort_plugins_by_dependencies(all_plugins)
sorted_plugins.reverse()
disabled_count = 0
for plugin_id in sorted_plugins:
cb = self.plugin_checkboxes.get(plugin_id)
if cb:
cb.setChecked(False) cb.setChecked(False)
plugin_manager.disable_plugin(plugin_id) success = plugin_manager.disable_plugin(plugin_id)
if success:
disabled_count += 1
self._refresh_plugin_list()
print(f"[Settings] Disabled {disabled_count} plugins")