diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-08-01 19:06:36 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-08-01 19:06:36 -0600 |
| commit | 0aa59c13af65beeae104662593b21ba4d8a37789 (patch) | |
| tree | 17a1e6bd92a3a236bef0bca2e11ca7a875ba6caa /ui | |
| parent | bfbdd0c19910f464e779fa64cc0ec8590f8e37c1 (diff) | |
Rename LabDAQ → LabUI and overhaul plugin system
Branding:
- Rename app, window title, file extension (.labdaq → .labui), user data
dirs (~/.labui/), spec file (labdaq.spec → labui.spec), and APP_NAME
throughout all source, docs, and config files
Plugin system:
- Plugins no longer bundled in the PyInstaller build — installed at
runtime by users via Settings → Plugins → Install Plugin (zip)
- PluginManager now takes user_dir + extra_scan_dirs; user plugins live
in ~/.labui/plugins/, dev scan additionally covers project plugins/
- install_from_zip / uninstall / is_user_installed added to PluginManager
- vendor/ dir inside plugin zips: prepended to sys.path at load time so
plugins can ship their own deps without requiring pip on end-user machine
- source_url field in manifest.json: shown as Download button in the
missing-plugins dialog when a profile requires an absent plugin
- Frozen-app pip install now targets ~/.labui/plugin_packages/ using a
real system Python (sys.executable is the exe in frozen builds)
Profile loading:
- Profile now stores plugins_manifest snapshot (id, name, version,
source_url) alongside plugins_enabled
- On load, missing or dep-broken plugins trigger MissingPluginsDialog
before the rest of the profile is applied; user can install from zip
or download via source_url in-dialog, or cancel the load
- Plugin reconciliation only enables installed plugins — missing ones
are not written to enabled.json
UI:
- Version label added to status bar (bottom-right, muted colour)
- Settings → Plugins tab: Install Plugin… button, per-plugin Remove
button for user-installed plugins, live list refresh after install/remove
Docs:
- New docs/building.md covers the full release pipeline
- docs/plugin-development.md updated for new install flow, vendoring,
source_url, profile behaviour, and distribution instructions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'ui')
| -rw-r--r-- | ui/main_window.py | 103 | ||||
| -rw-r--r-- | ui/profile_manager_ui.py | 10 | ||||
| -rw-r--r-- | ui/style.qss | 2 | ||||
| -rw-r--r-- | ui/style_dark.qss | 4 | ||||
| -rw-r--r-- | ui/style_light.qss | 3 | ||||
| -rw-r--r-- | ui/windows/missing_plugins_dialog.py | 242 | ||||
| -rw-r--r-- | ui/windows/settings_window.py | 103 |
7 files changed, 418 insertions, 49 deletions
diff --git a/ui/main_window.py b/ui/main_window.py index 9090d0f..efbb486 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -55,8 +55,7 @@ _LIGHT_QSS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "style_lig class MainWindow(QMainWindow): def __init__(self): super().__init__() - from core.version import __version__ - self.setWindowTitle(f"LabDAQ v{__version__}") + self.setWindowTitle("LabUI") self.setMinimumSize(1000, 640) self.registry = DeviceRegistry() @@ -71,9 +70,14 @@ class MainWindow(QMainWindow): self._win_settings = None self._win_debug = None - _plugins_dir = os.path.join(os.path.dirname(os.path.dirname( - os.path.abspath(__file__))), "plugins") - self._plugin_mgr = PluginManager(_plugins_dir) + import sys as _sys + _user_plugins = os.path.join(os.path.expanduser("~"), ".labui", "plugins") + _extra_dirs = [] + if not getattr(_sys, "frozen", False): + _project_plugins = os.path.join(os.path.dirname(os.path.dirname( + os.path.abspath(__file__))), "plugins") + _extra_dirs = [_project_plugins] + self._plugin_mgr = PluginManager(_user_plugins, extra_scan_dirs=_extra_dirs) self._plugin_mgr.discover() # {plugin_id: [QAction, ...]} toolbar actions to remove on unload self._plugin_toolbar_actions: dict = {} @@ -200,6 +204,10 @@ class MainWindow(QMainWindow): sb = QStatusBar(); self.setStatusBar(sb) self._status = QLabel("Ready"); sb.addWidget(self._status) self._log_lbl = QLabel(""); sb.addPermanentWidget(self._log_lbl) + from core.version import __version__ + _ver_lbl = QLabel(f"v{__version__}") + _ver_lbl.setObjectName("versionLabel") + sb.addPermanentWidget(_ver_lbl) self._clock = QTimer(self); self._clock.setInterval(1000) self._clock.timeout.connect(self._tick) @@ -333,27 +341,55 @@ class MainWindow(QMainWindow): self._install_plugin(plugin) def _pip_install(self, requirements: list) -> bool: - """Blocking `pip install` of the given requirement strings. - Returns True on success; shows a result dialog either way.""" + """Blocking pip install of the given requirement strings. + + In a frozen (PyInstaller) app sys.executable is the exe itself, so we + locate a real Python interpreter and install into ~/.labui/plugin_packages/ + which is added to sys.path at startup (see main.py). In dev mode the + normal sys.executable + site-packages path is used instead. + Returns True on success; shows a result dialog either way. + """ + import shutil import subprocess - import sys + import sys as _sys from PyQt6.QtWidgets import QMessageBox + frozen = getattr(_sys, "frozen", False) + + if frozen: + python = shutil.which("python3") or shutil.which("python") + if not python: + QMessageBox.critical( + self, "Install Failed", + "Could not find a Python interpreter on PATH.\n" + "Install the required packages manually:\n\n" + + "\n".join(f" pip install {r}" for r in requirements) + ) + return False + pkg_dir = os.path.join(os.path.expanduser("~"), ".labui", "plugin_packages") + os.makedirs(pkg_dir, exist_ok=True) + cmd = [python, "-m", "pip", "install", "--target", pkg_dir, *requirements] + else: + python = _sys.executable + cmd = [python, "-m", "pip", "install", *requirements] + QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) try: - result = subprocess.run( - [sys.executable, "-m", "pip", "install", *requirements], - capture_output=True, text=True, - ) + result = subprocess.run(cmd, capture_output=True, text=True) finally: QApplication.restoreOverrideCursor() if result.returncode == 0: + if frozen: + pkg_dir = os.path.join(os.path.expanduser("~"), ".labui", "plugin_packages") + if pkg_dir not in _sys.path: + _sys.path.insert(0, pkg_dir) QMessageBox.information( self, "Install Complete", f"Installed: {', '.join(requirements)}" ) return True + QMessageBox.critical( self, "Install Failed", f"pip install failed for: {', '.join(requirements)}\n\n" @@ -516,12 +552,53 @@ class MainWindow(QMainWindow): plugin_manager=self._plugin_mgr, ) + def _check_profile_plugins(self, profile: Profile) -> bool: + """Detect missing/broken plugins and prompt user to fix them. + Returns False if the user cancels the profile load. + + Two cases are caught: + 'plugin' — plugin not installed at all + 'deps' — plugin installed but its dependencies are absent + """ + if not profile.plugins_enabled: + return True + + installed_ids = {m.plugin_id for m in self._plugin_mgr.get_manifests()} + missing = [] + for pid in profile.plugins_enabled: + pm_info = next( + (m for m in profile.plugins_manifest if m.get("plugin_id") == pid), + {"plugin_id": pid, "name": pid, "version": "unknown", + "description": "", "requires": []}, + ) + if pid not in installed_ids: + missing.append({**pm_info, "kind": "plugin"}) + else: + absent_deps = self._plugin_mgr.get_missing_dependencies(pid) + if absent_deps: + missing.append({**pm_info, "kind": "deps", + "missing_deps": absent_deps}) + + if not missing: + return True + + from PyQt6.QtWidgets import QDialog + from ui.windows.missing_plugins_dialog import MissingPluginsDialog + dlg = MissingPluginsDialog(missing, self._plugin_mgr, self) + return dlg.exec() == QDialog.DialogCode.Accepted + def _profile_apply(self, profile: Profile): """Restore state from a Profile object.""" + if not self._check_profile_plugins(profile): + return + # Reconcile plugin enabled state before the rest of apply runs, # so plugin devices are present when channels/pipelines are restored. + # Only enable plugins that are actually installed — missing ones were + # handled (or skipped) by _check_profile_plugins. if profile.plugins_enabled is not None: - wanted = set(profile.plugins_enabled) + installed_ids = {m.plugin_id for m in self._plugin_mgr.get_manifests()} + wanted = set(profile.plugins_enabled) & installed_ids current = set(self._plugin_mgr.get_enabled_ids()) for pid in current - wanted: self.plugin_disable(pid) diff --git a/ui/profile_manager_ui.py b/ui/profile_manager_ui.py index 8966f8f..446e867 100644 --- a/ui/profile_manager_ui.py +++ b/ui/profile_manager_ui.py @@ -17,8 +17,8 @@ from PyQt6.QtGui import QAction from core.profile import Profile, ProfileManager -PROFILE_EXT = ".labdaq" -PROFILE_FILTER = f"LabDAQ Profile (*{PROFILE_EXT});;All files (*)" +PROFILE_EXT = ".labui" +PROFILE_FILTER = f"LabUI Profile (*{PROFILE_EXT});;All files (*)" class ProfileButton(QPushButton): @@ -134,7 +134,7 @@ class ProfileButton(QPushButton): # ── Helpers ─────────────────────────────────────────────────────────────── def _default_dir(self) -> str: - d = os.path.join(os.path.expanduser("~"), "labdaq_profiles") + d = os.path.join(os.path.expanduser("~"), "labui_profiles") os.makedirs(d, exist_ok=True) return d @@ -142,8 +142,8 @@ class ProfileButton(QPushButton): app = QApplication.instance() if app and hasattr(app, "topLevelWidgets"): for w in app.topLevelWidgets(): - if hasattr(w, "setWindowTitle") and "LabDAQ" in (w.windowTitle() or ""): - title = "LabDAQ" + if hasattr(w, "setWindowTitle") and "LabUI" in (w.windowTitle() or ""): + title = "LabUI" if name: title += f" — {name}" if self._current_path: diff --git a/ui/style.qss b/ui/style.qss index 3761c68..aa11afb 100644 --- a/ui/style.qss +++ b/ui/style.qss @@ -1,4 +1,4 @@ -/* LabDAQ — Industrial Dark Theme +/* LabUI — Industrial Dark Theme Font stack: IBM Plex Mono (monospace data), IBM Plex Sans (UI labels) Palette: bg-base #0b0e13 diff --git a/ui/style_dark.qss b/ui/style_dark.qss index 9fd7198..431459d 100644 --- a/ui/style_dark.qss +++ b/ui/style_dark.qss @@ -1,4 +1,4 @@ -/* LabDAQ — Industrial Dark Theme +/* LabUI — Industrial Dark Theme Palette: bg-base #0b0e13 bg-panel #111620 bg-card #161d2e bg-raised #1c2540 border #2a3558 @@ -384,6 +384,8 @@ QStatusBar { color: #8b9dc3; font-family: "IBM Plex Mono", monospace; font-size: 12px; } +QLabel#versionLabel { font-family: "IBM Plex Mono", monospace; font-size: 10px; color: #8b9dc3; padding-right: 6px; } + /* ── Default button ───────────────────────────────────────────────── */ QPushButton { background-color: #1c2540; color: #8b9dc3; diff --git a/ui/style_light.qss b/ui/style_light.qss index 265894d..ff204fb 100644 --- a/ui/style_light.qss +++ b/ui/style_light.qss @@ -1,4 +1,4 @@ -/* LabDAQ — Light Theme */ +/* LabUI — Light Theme */ * { font-family:"IBM Plex Sans","Segoe UI",Tahoma,sans-serif; font-size:12px; color:#1e293b; } QMainWindow,QDialog { background:#f8fafc; } QToolBar#mainToolbar { background:#ffffff; border-bottom:1px solid #e2e8f0; padding:4px 8px; spacing:4px; } @@ -49,6 +49,7 @@ QScrollBar::add-line:vertical,QScrollBar::sub-line:vertical { height:0; } QSplitter::handle { background:#e2e8f0; } QSplitter::handle:hover { background:#3b82f6; } QStatusBar { background:#ffffff; border-top:1px solid #e2e8f0; color:#64748b; font-family:"IBM Plex Mono",monospace; font-size:11px; } +QLabel#versionLabel { font-family:"IBM Plex Mono",monospace; font-size:10px; color:#64748b; padding-right:6px; } QPushButton { background:#f1f5f9; color:#475569; border:1px solid #cbd5e1; border-radius:4px; padding:5px 12px; } QPushButton:hover { background:#e2e8f0; color:#1e293b; } QTextEdit#codeEditor { font-family:"IBM Plex Mono",monospace; font-size:12px; background:#1e293b; color:#e2e8f0; border:1px solid #cbd5e1; border-radius:4px; padding:6px; } diff --git a/ui/windows/missing_plugins_dialog.py b/ui/windows/missing_plugins_dialog.py new file mode 100644 index 0000000..4a183d3 --- /dev/null +++ b/ui/windows/missing_plugins_dialog.py @@ -0,0 +1,242 @@ +""" +ui/windows/missing_plugins_dialog.py + +Shown when loading a profile that requires plugins that are either not +installed or installed but missing their dependencies. + +Each row is one of two kinds: + 'plugin' — plugin not installed → Download (if source_url) + Install from file… + 'deps' — plugin installed but deps absent → Reinstall from file… +""" + +import os +import tempfile + +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QFrame, QFileDialog, QMessageBox, QApplication, +) +from PyQt6.QtCore import Qt + + +class MissingPluginsDialog(QDialog): + def __init__(self, missing_manifests: list, plugin_mgr, parent=None): + super().__init__(parent, Qt.WindowType.Dialog) + self._plugin_mgr = plugin_mgr + self._missing = missing_manifests + self._install_btns: dict = {} # plugin_id -> "Install from file…" button + self._download_btns: dict = {} # plugin_id -> "Download" button (optional) + self._status_lbls: dict = {} # plugin_id -> status QLabel + self.setWindowTitle("Missing Plugins") + self.setMinimumWidth(540) + self._build() + + # ── Build ───────────────────────────────────────────────────────────── + + def _build(self): + root = QVBoxLayout(self) + root.setSpacing(12) + root.setContentsMargins(16, 16, 16, 16) + + intro = QLabel( + "This profile requires plugins that are not ready on this machine.\n" + "Fix the issues below, then click <b>Continue</b> to finish loading." + ) + intro.setWordWrap(True) + root.addWidget(intro) + + root.addWidget(_hline()) + + for pm in self._missing: + root.addLayout(self._plugin_row(pm)) + + root.addWidget(_hline()) + + btn_row = QHBoxLayout() + btn_row.addStretch() + + cancel_btn = QPushButton("Cancel load") + cancel_btn.clicked.connect(self.reject) + btn_row.addWidget(cancel_btn) + + self._continue_btn = QPushButton("Continue") + self._continue_btn.setObjectName("applyButton") + self._continue_btn.clicked.connect(self.accept) + btn_row.addWidget(self._continue_btn) + + root.addLayout(btn_row) + self._refresh_continue_btn() + + def _plugin_row(self, pm: dict) -> QHBoxLayout: + row = QHBoxLayout(); row.setSpacing(8) + + # Left: name + detail + info_col = QVBoxLayout(); info_col.setSpacing(2) + name_lbl = QLabel(f"<b>{pm.get('name', pm['plugin_id'])}</b>" + f" <small>v{pm.get('version', '?')}</small>") + info_col.addWidget(name_lbl) + + if pm.get("kind") == "deps": + detail = "Missing packages: " + ", ".join(pm.get("missing_deps", [])) + elif pm.get("description"): + detail = pm["description"] + else: + detail = "" + if detail: + dl = QLabel(detail) + dl.setObjectName("traceSource"); dl.setWordWrap(True) + info_col.addWidget(dl) + row.addLayout(info_col, 1) + + # Status label + is_deps = pm.get("kind") == "deps" + status_lbl = QLabel("Deps missing" if is_deps else "Not installed") + status_lbl.setObjectName("traceSource") + status_lbl.setFixedWidth(130) + status_lbl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + self._status_lbls[pm["plugin_id"]] = status_lbl + row.addWidget(status_lbl) + + # Download button — only for missing plugins with a source_url + source_url = pm.get("source_url", "") + if source_url and not is_deps: + dl_btn = QPushButton("Download") + dl_btn.setObjectName("configButton") + dl_btn.setFixedWidth(90) + dl_btn.setToolTip(f"Download from:\n{source_url}") + dl_btn.clicked.connect(lambda _, p=pm: self._download(p)) + self._download_btns[pm["plugin_id"]] = dl_btn + row.addWidget(dl_btn) + + # Install / Reinstall from file button + btn_label = "Reinstall from file…" if is_deps else "Install from file…" + inst_btn = QPushButton(btn_label) + inst_btn.setObjectName("configButton") + inst_btn.setFixedWidth(150) + if is_deps: + inst_btn.setToolTip("Reinstall with a zip that bundles deps in vendor/") + inst_btn.clicked.connect(lambda _, p=pm: self._pick_file(p)) + self._install_btns[pm["plugin_id"]] = inst_btn + row.addWidget(inst_btn) + + return row + + # ── Install paths ───────────────────────────────────────────────────── + + def _pick_file(self, pm: dict): + path, _ = QFileDialog.getOpenFileName( + self, f"Install {pm.get('name', pm['plugin_id'])}", "", + "Plugin Archives (*.zip)" + ) + if path: + self._finish_install(pm, path, cleanup=False) + + def _download(self, pm: dict): + import urllib.request + + url = pm.get("source_url", "") + if not url: + return + + pid = pm["plugin_id"] + self._set_row_busy(pid, True, "Downloading…") + + tmp_path = None + try: + tmp_fd, tmp_path = tempfile.mkstemp(suffix=".zip") + os.close(tmp_fd) + + status_lbl = self._status_lbls[pid] + + def _progress(block_count, block_size, total): + if total > 0: + pct = min(100, block_count * block_size * 100 // total) + status_lbl.setText(f"Downloading… {pct}%") + QApplication.processEvents() + + urllib.request.urlretrieve(url, tmp_path, reporthook=_progress) + + except Exception as exc: + self._set_row_busy(pid, False, "Download failed") + QMessageBox.critical(self, "Download Failed", str(exc)) + if tmp_path: + _silent_remove(tmp_path) + return + + self._status_lbls[pid].setText("Installing…") + QApplication.processEvents() + self._finish_install(pm, tmp_path, cleanup=True) + + def _finish_install(self, pm: dict, zip_path: str, cleanup: bool): + pid = pm["plugin_id"] + try: + manifest = self._plugin_mgr.install_from_zip(zip_path) + except Exception as exc: + self._set_row_busy(pid, False, + "Deps missing" if pm.get("kind") == "deps" else "Not installed") + QMessageBox.critical(self, "Install Failed", str(exc)) + return + finally: + if cleanup: + _silent_remove(zip_path) + + if manifest.plugin_id != pid: + self._set_row_busy(pid, False, + "Deps missing" if pm.get("kind") == "deps" else "Not installed") + QMessageBox.warning( + self, "Wrong Plugin", + f"Expected '{pid}' but the zip contains '{manifest.plugin_id}'." + ) + return + + remaining = self._plugin_mgr.get_missing_dependencies(pid) + if remaining: + self._set_row_busy(pid, False, "Deps still missing") + QMessageBox.warning( + self, "Dependencies Still Missing", + "Plugin installed but these packages are still absent:\n\n" + + "\n".join(f" {r}" for r in remaining) + + "\n\nRe-zip the plugin with a vendor/ folder containing its dependencies." + ) + return + + self._status_lbls[pid].setText("✓ Ready") + self._set_row_busy(pid, False, None) # None = keep status as-is + if pid in self._install_btns: + self._install_btns[pid].setEnabled(False) + if pid in self._download_btns: + self._download_btns[pid].setEnabled(False) + self._refresh_continue_btn() + + # ── Helpers ─────────────────────────────────────────────────────────── + + def _set_row_busy(self, plugin_id: str, busy: bool, status_text: str | None): + if status_text is not None: + self._status_lbls[plugin_id].setText(status_text) + for d in (self._install_btns, self._download_btns): + btn = d.get(plugin_id) + if btn: + btn.setEnabled(not busy) + QApplication.processEvents() + + def _refresh_continue_btn(self): + installed_ids = {m.plugin_id for m in self._plugin_mgr.get_manifests()} + all_resolved = all( + pid in installed_ids + and not self._plugin_mgr.get_missing_dependencies(pid) + for pid in (pm["plugin_id"] for pm in self._missing) + ) + self._continue_btn.setText("Continue ✓" if all_resolved else "Continue") + + +# ── Utilities ────────────────────────────────────────────────────────────────── + +def _hline() -> QFrame: + f = QFrame(); f.setFrameShape(QFrame.Shape.HLine) + return f + +def _silent_remove(path: str): + try: + os.unlink(path) + except Exception: + pass diff --git a/ui/windows/settings_window.py b/ui/windows/settings_window.py index 75b6991..96a99a5 100644 --- a/ui/windows/settings_window.py +++ b/ui/windows/settings_window.py @@ -52,6 +52,7 @@ class SettingsWindow(QWidget): self.engine = engine self._plugin_mgr = plugin_manager self._plugin_buttons: dict = {} # plugin_id -> QPushButton + self._plugins_scroll = None self.cfg = dict(self._defaults) if current: self.cfg.update(current) @@ -207,48 +208,57 @@ class SettingsWindow(QWidget): def _plugins_tab(self): w = QWidget() + vl = QVBoxLayout(w); vl.setContentsMargins(0,0,0,0); vl.setSpacing(0) + + # Install bar + bar = QWidget(); bar.setObjectName("cfgBottomBar") + bl = QHBoxLayout(bar); bl.setContentsMargins(12,6,12,6) + bl.addStretch() + inst_btn = QPushButton("Install Plugin…"); inst_btn.setObjectName("configButton") + inst_btn.clicked.connect(self._install_plugin_from_file) + bl.addWidget(inst_btn) + vl.addWidget(bar) + scroll = QScrollArea(); scroll.setWidgetResizable(True) scroll.setObjectName("deviceScroll") - cont = QWidget(); lay = QVBoxLayout(cont) + self._plugins_scroll = scroll + vl.addWidget(scroll, 1) + self._refresh_plugins_list() + return w + + def _refresh_plugins_list(self): + self._plugin_buttons.clear() + cont = QWidget() + lay = QVBoxLayout(cont) lay.setContentsMargins(14, 12, 14, 12); lay.setSpacing(10) if self._plugin_mgr is None: lay.addWidget(QLabel("Plugin manager not available.")) lay.addStretch() - scroll.setWidget(cont) - root = QVBoxLayout(w); root.setContentsMargins(0,0,0,0) - root.addWidget(scroll); return w + self._plugins_scroll.setWidget(cont) + return manifests = self._plugin_mgr.get_manifests() - if not manifests: info = QLabel( - "No plugins found.\n\n" - "Drop a plugin folder into the plugins/ directory next to main.py.\n" - "Each plugin needs a manifest.json and a plugin.py." + "No plugins installed.\n\n" + "Click 'Install Plugin…' above to install a plugin from a .zip file." ) info.setObjectName("traceSource"); info.setWordWrap(True) lay.addWidget(info) lay.addStretch() - scroll.setWidget(cont) - root = QVBoxLayout(w); root.setContentsMargins(0,0,0,0) - root.addWidget(scroll); return w + self._plugins_scroll.setWidget(cont) + return for manifest in manifests: lay.addWidget(self._plugin_card(manifest)) - lay.addStretch() - scroll.setWidget(cont) - root = QVBoxLayout(w); root.setContentsMargins(0,0,0,0) - root.addWidget(scroll); return w + self._plugins_scroll.setWidget(cont) def _plugin_card(self, manifest): - """One card per discovered plugin.""" - card = QGroupBox() - card.setObjectName("pluginCard") + card = QGroupBox(); card.setObjectName("pluginCard") cl = QVBoxLayout(card); cl.setContentsMargins(10, 8, 10, 8); cl.setSpacing(4) - # Header row: name + version + enable toggle hdr = QHBoxLayout() name_lbl = QLabel(f"<b>{manifest.name}</b> <small>v{manifest.version}</small>") name_lbl.setObjectName("traceLabel") @@ -256,33 +266,36 @@ class SettingsWindow(QWidget): enabled = self._plugin_mgr.is_enabled(manifest.plugin_id) toggle = QPushButton("Disable" if enabled else "Enable") - toggle.setObjectName("configButton") - toggle.setFixedWidth(72) + toggle.setObjectName("configButton"); toggle.setFixedWidth(72) toggle.clicked.connect( lambda _, pid=manifest.plugin_id, btn=toggle: self._toggle_plugin(pid, btn) ) self._plugin_buttons[manifest.plugin_id] = toggle hdr.addWidget(toggle) + + if self._plugin_mgr.is_user_installed(manifest.plugin_id): + rm_btn = QPushButton("Remove") + rm_btn.setObjectName("configButton"); rm_btn.setFixedWidth(72) + rm_btn.clicked.connect( + lambda _, pid=manifest.plugin_id, pname=manifest.name: self._remove_plugin(pid, pname) + ) + hdr.addWidget(rm_btn) + cl.addLayout(hdr) - # Description / author if manifest.description: desc = QLabel(manifest.description) desc.setObjectName("traceSource"); desc.setWordWrap(True) cl.addWidget(desc) - if manifest.author: - author = QLabel(f"Author: {manifest.author}") - author.setObjectName("traceSource") - cl.addWidget(author) + cl.addWidget(QLabel(f"Author: {manifest.author}").also( + lambda w: w.setObjectName("traceSource"))) - # Plugin-specific settings widget (only when loaded) plugin = self._plugin_mgr.get_plugin(manifest.plugin_id) if plugin: sw = plugin.get_settings_widget() if sw is not None: cl.addWidget(sw) - return card def _toggle_plugin(self, plugin_id: str, btn: QPushButton): @@ -301,6 +314,40 @@ class SettingsWindow(QWidget): if btn is not None and self._plugin_mgr is not None: btn.setText("Disable" if self._plugin_mgr.is_enabled(plugin_id) else "Enable") + def _install_plugin_from_file(self): + path, _ = QFileDialog.getOpenFileName( + self, "Install Plugin", "", "Plugin Archives (*.zip)" + ) + if not path: + return + try: + manifest = self._plugin_mgr.install_from_zip(path) + self._refresh_plugins_list() + QMessageBox.information( + self, "Plugin Installed", + f"'{manifest.name}' v{manifest.version} installed successfully." + ) + except Exception as exc: + QMessageBox.critical(self, "Install Failed", str(exc)) + + def _remove_plugin(self, plugin_id: str, plugin_name: str): + if self._plugin_mgr.is_enabled(plugin_id): + QMessageBox.warning(self, "Cannot Remove", + "Disable the plugin before removing it.") + return + reply = QMessageBox.question( + self, "Remove Plugin", + f"Remove '{plugin_name}'? This deletes the plugin files.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + try: + self._plugin_mgr.uninstall(plugin_id) + self._refresh_plugins_list() + except Exception as exc: + QMessageBox.critical(self, "Remove Failed", str(exc)) + # ── Actions ─────────────────────────────────────────────────────────── def _check_for_updates(self): |
