"""
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,
)
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,
}
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.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)
scroll.setWidget(cont)
root = QVBoxLayout(w); root.setContentsMargins(0,0,0,0); root.addWidget(scroll)
return w
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)
)
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:
self.plugin_enable_requested.emit(plugin_id)
btn.setText("Disable")
# ── Actions ───────────────────────────────────────────────────────────
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(),
"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