""" 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": False, } 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, and enables verbose DEBUG output in\n" "the terminal. Off = real-hardware-only, no debug tools." ) lay.addRow("Developer mode:", self._dev_chk) 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 _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