summaryrefslogtreecommitdiff
path: root/ui/windows/settings_window.py
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-08-01 19:06:36 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-08-01 19:06:36 -0600
commit0aa59c13af65beeae104662593b21ba4d8a37789 (patch)
tree17a1e6bd92a3a236bef0bca2e11ca7a875ba6caa /ui/windows/settings_window.py
parentbfbdd0c19910f464e779fa64cc0ec8590f8e37c1 (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/windows/settings_window.py')
-rw-r--r--ui/windows/settings_window.py103
1 files changed, 75 insertions, 28 deletions
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):