From b63763b52895fb7ae6b46d5f467c60f2431cee2a Mon Sep 17 00:00:00 2001 From: LemonNexus Date: Sun, 15 Feb 2026 01:25:49 +0000 Subject: [PATCH] feat: Enhanced Plugin Management UI with Dependency Visualization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- plugins/settings/plugin.py | 533 +++++++++++++++++++++++++++++++++---- 1 file changed, 485 insertions(+), 48 deletions(-) diff --git a/plugins/settings/plugin.py b/plugins/settings/plugin.py index 51e509f..4cdc917 100644 --- a/plugins/settings/plugin.py +++ b/plugins/settings/plugin.py @@ -184,16 +184,41 @@ class SettingsPlugin(BasePlugin): return tab def _create_plugins_tab(self): - """Create plugins management tab - enable/disable plugins.""" + """Create plugins management tab with dependency visualization.""" tab = QWidget() layout = QVBoxLayout(tab) layout.setSpacing(15) # 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);") 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 = QScrollArea() scroll.setWidgetResizable(True) @@ -206,51 +231,23 @@ class SettingsPlugin(BasePlugin): plugins_layout.setAlignment(Qt.AlignmentFlag.AlignTop) self.plugin_checkboxes = {} + self.plugin_dependency_labels = {} + self.plugin_rows = {} # Get all discovered plugins from plugin manager if hasattr(self.overlay, 'plugin_manager'): plugin_manager = self.overlay.plugin_manager all_plugins = plugin_manager.get_all_discovered_plugins() + # Build dependency maps + self._build_dependency_maps(all_plugins) + # Sort by name sorted_plugins = sorted(all_plugins.items(), key=lambda x: x[1].name) for plugin_id, plugin_class in sorted_plugins: - row = QHBoxLayout() - - # 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) + plugin_row = self._create_plugin_row(plugin_id, plugin_class, plugin_manager) + plugins_layout.addLayout(plugin_row) # Separator sep = QFrame() @@ -292,11 +289,234 @@ class SettingsPlugin(BasePlugin): disable_all_btn.clicked.connect(self._disable_all_plugins) 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() layout.addLayout(btn_layout) 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): """Create hotkeys configuration tab.""" tab = QWidget() @@ -520,42 +740,259 @@ class SettingsPlugin(BasePlugin): pass 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'): return plugin_manager = self.overlay.plugin_manager 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) if success: print(f"[Settings] Enabled plugin: {plugin_id}") + # Refresh UI to show auto-enabled plugins + self._refresh_plugin_list() else: print(f"[Settings] Failed to enable plugin: {plugin_id}") 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) if success: print(f"[Settings] Disabled plugin: {plugin_id}") + self._refresh_plugin_list() + + def _get_missing_dependencies(self, plugin_id: str, plugin_manager) -> list: + """Get list of dependencies that need to be enabled.""" + deps = self.plugin_deps.get(plugin_id, []) + missing = [] - # Notify user a restart might be needed for some changes - # But we try to apply immediately + 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 "

No plugin manager available

" + + plugin_manager = self.overlay.plugin_manager + all_plugins = plugin_manager.get_all_discovered_plugins() + + html = [""] + html.append("

📋 Plugin Dependency Report

") + html.append("
") + + # Summary section + total = len(all_plugins) + enabled = sum(1 for pid in all_plugins if plugin_manager.is_plugin_enabled(pid)) + html.append(f"

Total Plugins: {total} | Enabled: {enabled}

") + html.append("
") + + # Plugins with dependencies + html.append("

🔗 Plugins with Dependencies

") + html.append("") + html.append("
") + + # Plugins required by others + html.append("

⚠️ Plugins Required by Others

") + html.append("") + html.append("
") + + # Dependency chain visualization + html.append("

🔄 Dependency Chains

") + html.append("") + html.append("") + + 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): - """Enable all plugins.""" + """Enable all plugins with dependency resolution.""" if not hasattr(self.overlay, 'plugin_manager'): return plugin_manager = self.overlay.plugin_manager - for plugin_id, cb in self.plugin_checkboxes.items(): - cb.setChecked(True) - plugin_manager.enable_plugin(plugin_id) + 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) + 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): - """Disable all plugins.""" + """Disable all plugins in reverse dependency order.""" if not hasattr(self.overlay, 'plugin_manager'): return plugin_manager = self.overlay.plugin_manager - for plugin_id, cb in self.plugin_checkboxes.items(): - cb.setChecked(False) - plugin_manager.disable_plugin(plugin_id) + 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) + success = plugin_manager.disable_plugin(plugin_id) + if success: + disabled_count += 1 + + self._refresh_plugin_list() + print(f"[Settings] Disabled {disabled_count} plugins")