diff options
Diffstat (limited to 'ui/windows')
| -rw-r--r-- | ui/windows/missing_plugins_dialog.py | 242 | ||||
| -rw-r--r-- | ui/windows/settings_window.py | 103 |
2 files changed, 317 insertions, 28 deletions
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): |
