"""
ui/windows/settings_window.py
SETTINGS window — toolbar section 5.
Tabs:
General — theme (dark/light/system), font sizes, polling rate
Acquisition — sample rate, buffer size, CSV log directory
Display — default time window, legend, grid defaults
"""
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QTabWidget, QFrame, QFormLayout, QComboBox,
QSpinBox, QDoubleSpinBox, QCheckBox, QLineEdit,
QFileDialog, QScrollArea, QGroupBox, QMessageBox,
)
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QCloseEvent, QFont
from devices.device_registry import DeviceRegistry
from core.acquisition import AcquisitionEngine
class SettingsWindow(QWidget):
theme_changed = pyqtSignal(str) # "dark" | "light"
settings_changed = pyqtSignal(dict)
plugin_enable_requested = pyqtSignal(str) # plugin_id
plugin_disable_requested= pyqtSignal(str) # plugin_id
closed = pyqtSignal()
# Shared settings dict — written on Apply, read by consumers
_defaults = {
"theme": "dark",
"poll_ms": 100,
"buffer_size": 20000,
"log_dir": "logs",
"time_window_s": 30.0,
"show_legend": True,
"show_grid": True,
"font_size": 12,
"antialias": True,
"developer_mode": True,
}
def __init__(self, registry: DeviceRegistry,
engine: AcquisitionEngine,
current: dict = None, parent=None, *,
plugin_manager=None):
super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool)
self.registry = registry
self.engine = engine
self._plugin_mgr = plugin_manager
self._plugin_buttons: dict = {} # plugin_id -> QPushButton
self.cfg = dict(self._defaults)
if current:
self.cfg.update(current)
self.setWindowTitle("Settings")
self.setMinimumSize(520, 440)
self.resize(560, 520)
self._build()
def _build(self):
root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0); root.setSpacing(0)
# Header
hdr = QWidget(); hdr.setObjectName("devWindowTitleBar"); hdr.setFixedHeight(44)
hl = QHBoxLayout(hdr); hl.setContentsMargins(14,0,14,0)
hl.addWidget(QLabel("SETTINGS").also(lambda w: w.setObjectName("devWindowTitle")))
root.addWidget(hdr)
div = QFrame(); div.setFrameShape(QFrame.Shape.HLine)
div.setObjectName("devWindowDivider"); root.addWidget(div)
tabs = QTabWidget(); tabs.setObjectName("signalBuilderTabs")
root.addWidget(tabs, 1)
tabs.addTab(self._general_tab(), " General ")
tabs.addTab(self._acquisition_tab(), " Acquisition ")
tabs.addTab(self._display_tab(), " Display ")
tabs.addTab(self._plugins_tab(), " Plugins ")
# Bottom bar
btm = QWidget(); btm.setObjectName("cfgBottomBar")
bl = QHBoxLayout(btm); bl.setContentsMargins(12,8,12,8)
bl.addStretch()
ap = QPushButton("✓ Apply & Close"); ap.setObjectName("applyButton")
ap.clicked.connect(self._apply); bl.addWidget(ap)
root.addWidget(btm)
# ── Tabs ──────────────────────────────────────────────────────────────
def _general_tab(self):
w = QWidget()
scroll = QScrollArea(); scroll.setWidgetResizable(True)
scroll.setObjectName("deviceScroll")
cont = QWidget(); lay = QFormLayout(cont)
lay.setContentsMargins(16,14,16,14); lay.setSpacing(10)
self._theme_cb = QComboBox(); self._theme_cb.setObjectName("channelPickerCb")
self._theme_cb.addItems(["Dark", "Light"])
self._theme_cb.setCurrentText(self.cfg["theme"].title())
lay.addRow("Theme:", self._theme_cb)
self._font_sp = QSpinBox(); self._font_sp.setRange(8,18)
self._font_sp.setValue(self.cfg["font_size"]); self._font_sp.setSuffix(" pt")
self._font_sp.setObjectName("traceWidthSpin")
lay.addRow("Base font size:", self._font_sp)
self._aa_chk = QCheckBox(); self._aa_chk.setChecked(self.cfg["antialias"])
lay.addRow("Anti-alias plots:", self._aa_chk)
self._dev_chk = QCheckBox()
self._dev_chk.setChecked(self.cfg["developer_mode"])
self._dev_chk.setToolTip(
"Controls whether the Debug window and each device's Simulation\n"
"Mode option are available. Off = real-hardware-only, no debug tools."
)
lay.addRow("Developer mode:", self._dev_chk)
from core.version import __version__ as _app_version
version_lbl = QLabel(f"v{_app_version}")
version_lbl.setObjectName("traceSource")
lay.addRow("Version:", version_lbl)
update_row = QHBoxLayout()
self._update_btn = QPushButton("Check for Updates")
self._update_btn.setObjectName("configButton")
self._update_btn.clicked.connect(self._check_for_updates)
update_row.addWidget(self._update_btn)
update_row.addStretch()
lay.addRow("Updates:", update_row)
self._update_status_lbl = QLabel("")
self._update_status_lbl.setObjectName("traceSource")
self._update_status_lbl.setWordWrap(True)
lay.addRow("", self._update_status_lbl)
rst = QPushButton("Reset to Defaults"); rst.setObjectName("configButton")
rst.clicked.connect(self._reset_to_defaults)
lay.addRow("", rst)
scroll.setWidget(cont)
root = QVBoxLayout(w); root.setContentsMargins(0,0,0,0); root.addWidget(scroll)
return w
def _populate_from_cfg(self):
self._theme_cb.setCurrentText(self.cfg["theme"].title())
self._font_sp.setValue(self.cfg["font_size"])
self._aa_chk.setChecked(self.cfg["antialias"])
self._dev_chk.setChecked(self.cfg["developer_mode"])
self._poll_sp.setValue(self.cfg["poll_ms"])
self._buf_sp.setValue(self.cfg["buffer_size"])
self._log_edit.setText(self.cfg["log_dir"])
self._tw_sp.setValue(self.cfg["time_window_s"])
self._legend_chk.setChecked(self.cfg["show_legend"])
self._grid_chk.setChecked(self.cfg["show_grid"])
def _acquisition_tab(self):
w = QWidget()
scroll = QScrollArea(); scroll.setWidgetResizable(True)
scroll.setObjectName("deviceScroll")
cont = QWidget(); lay = QFormLayout(cont)
lay.setContentsMargins(16,14,16,14); lay.setSpacing(10)
self._poll_sp = QSpinBox(); self._poll_sp.setRange(10,5000)
self._poll_sp.setValue(self.cfg["poll_ms"]); self._poll_sp.setSuffix(" ms")
self._poll_sp.setObjectName("traceWidthSpin")
lay.addRow("Poll interval:", self._poll_sp)
self._buf_sp = QSpinBox(); self._buf_sp.setRange(1000,500000)
self._buf_sp.setValue(self.cfg["buffer_size"])
self._buf_sp.setObjectName("traceWidthSpin")
lay.addRow("Buffer size (samples):", self._buf_sp)
log_row = QHBoxLayout()
self._log_edit = QLineEdit(self.cfg["log_dir"])
self._log_edit.setObjectName("traceLabel"); log_row.addWidget(self._log_edit,1)
br = QPushButton("Browse"); br.setObjectName("configButton")
br.clicked.connect(self._browse_log); log_row.addWidget(br)
lay.addRow("Log directory:", log_row)
scroll.setWidget(cont)
root = QVBoxLayout(w); root.setContentsMargins(0,0,0,0); root.addWidget(scroll)
return w
def _display_tab(self):
w = QWidget()
scroll = QScrollArea(); scroll.setWidgetResizable(True)
scroll.setObjectName("deviceScroll")
cont = QWidget(); lay = QFormLayout(cont)
lay.setContentsMargins(16,14,16,14); lay.setSpacing(10)
self._tw_sp = QDoubleSpinBox(); self._tw_sp.setRange(1,3600)
self._tw_sp.setValue(self.cfg["time_window_s"]); self._tw_sp.setSuffix(" s")
self._tw_sp.setObjectName("cfgGlobalSpin")
lay.addRow("Default time window:", self._tw_sp)
self._legend_chk = QCheckBox(); self._legend_chk.setChecked(self.cfg["show_legend"])
lay.addRow("Show legend by default:", self._legend_chk)
self._grid_chk = QCheckBox(); self._grid_chk.setChecked(self.cfg["show_grid"])
lay.addRow("Show grid by default:", self._grid_chk)
scroll.setWidget(cont)
root = QVBoxLayout(w); root.setContentsMargins(0,0,0,0); root.addWidget(scroll)
return w
def _plugins_tab(self):
w = QWidget()
scroll = QScrollArea(); scroll.setWidgetResizable(True)
scroll.setObjectName("deviceScroll")
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
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."
)
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
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
def _plugin_card(self, manifest):
"""One card per discovered plugin."""
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"{manifest.name} v{manifest.version}")
name_lbl.setObjectName("traceLabel")
hdr.addWidget(name_lbl, 1)
enabled = self._plugin_mgr.is_enabled(manifest.plugin_id)
toggle = QPushButton("Disable" if enabled else "Enable")
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)
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)
# 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):
if self._plugin_mgr.is_enabled(plugin_id):
self.plugin_disable_requested.emit(plugin_id)
btn.setText("Enable")
else:
# Don't flip to "Disable" yet — enabling can fail (missing
# dependencies, bad plugin code). main_window confirms the
# real outcome via sync_plugin_button() once enable() returns.
self.plugin_enable_requested.emit(plugin_id)
def sync_plugin_button(self, plugin_id: str):
"""Refresh one plugin's toggle button to match its actual enabled state."""
btn = self._plugin_buttons.get(plugin_id)
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")
# ── Actions ───────────────────────────────────────────────────────────
def _check_for_updates(self):
from core.updater import is_frozen
if not is_frozen():
self._update_status_lbl.setText("Not available outside the packaged app (dev mode).")
return
from PyQt6.QtWidgets import QApplication
from core.updater import apply_update, check_for_update
self._update_btn.setEnabled(False)
self._update_status_lbl.setText("Checking…")
QApplication.processEvents()
try:
new_version = check_for_update()
except Exception as e:
self._update_status_lbl.setText(f"Check failed: {e}")
self._update_btn.setEnabled(True)
return
self._update_btn.setEnabled(True)
if not new_version:
self._update_status_lbl.setText("Up to date.")
return
self._update_status_lbl.setText(f"Version {new_version} available.")
reply = QMessageBox.question(
self, "Update Available",
f"Version {new_version} is available. Download and install now?\n\n"
f"The app will close and relaunch to complete the update.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
try:
apply_update() # may close/relaunch the process during this call
except Exception as e:
QMessageBox.critical(self, "Update Failed", str(e))
def _reset_to_defaults(self):
reply = QMessageBox.question(
self, "Reset Settings",
"Reset all settings to defaults?\nThis cannot be undone.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
self.cfg = dict(self._defaults)
self._populate_from_cfg()
def _browse_log(self):
path = QFileDialog.getExistingDirectory(self, "Select Log Directory")
if path:
self._log_edit.setText(path)
def _apply(self):
theme = self._theme_cb.currentText().lower()
self.cfg.update({
"theme": theme,
"font_size": self._font_sp.value(),
"antialias": self._aa_chk.isChecked(),
"developer_mode": self._dev_chk.isChecked(),
"poll_ms": self._poll_sp.value(),
"buffer_size": self._buf_sp.value(),
"log_dir": self._log_edit.text(),
"time_window_s": self._tw_sp.value(),
"show_legend": self._legend_chk.isChecked(),
"show_grid": self._grid_chk.isChecked(),
})
self.theme_changed.emit(theme)
self.settings_changed.emit(dict(self.cfg))
self.hide()
def closeEvent(self, e: QCloseEvent):
self.closed.emit(); e.accept()
# monkey-patch QLabel.also
from PyQt6.QtWidgets import QLabel as _QL
def _also3(self, fn): fn(self); return self
if not hasattr(_QL, "also"): _QL.also = _also3