summaryrefslogtreecommitdiff
path: root/ui
diff options
context:
space:
mode:
Diffstat (limited to 'ui')
-rw-r--r--ui/main_window.py103
-rw-r--r--ui/profile_manager_ui.py10
-rw-r--r--ui/style.qss2
-rw-r--r--ui/style_dark.qss4
-rw-r--r--ui/style_light.qss3
-rw-r--r--ui/windows/missing_plugins_dialog.py242
-rw-r--r--ui/windows/settings_window.py103
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):