From d5acb04b88373d33b038bb59945fb5ab8b4f543b Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Mon, 20 Apr 2026 16:55:57 -0600 Subject: V8 --- ui/windows/settings_window.py | 231 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 ui/windows/settings_window.py (limited to 'ui/windows/settings_window.py') diff --git a/ui/windows/settings_window.py b/ui/windows/settings_window.py new file mode 100644 index 0000000..88093ff --- /dev/null +++ b/ui/windows/settings_window.py @@ -0,0 +1,231 @@ +""" +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 + Controls — configure output widget assignments per channel +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QTabWidget, QFrame, QFormLayout, QGroupBox, QComboBox, + QSpinBox, QDoubleSpinBox, QCheckBox, QLineEdit, + QFileDialog, QScrollArea, QSizePolicy, +) +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) + 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): + super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool) + self.registry = registry + self.engine = engine + 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._controls_tab(), " Controls ") + + # 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 _controls_tab(self): + """Output channel assignments — which device channel each control widget drives.""" + w = QWidget() + scroll = QScrollArea(); scroll.setWidgetResizable(True) + scroll.setObjectName("deviceScroll") + cont = QWidget(); lay = QVBoxLayout(cont) + lay.setContentsMargins(14,12,14,12); lay.setSpacing(8) + + info = QLabel( + "Configure which physical output channels are driven by each control widget.\n" + "Add output widget mappings below. Changes take effect on next app start." + ) + info.setObjectName("traceSource"); info.setWordWrap(True) + lay.addWidget(info) + + grp = QGroupBox("Output Assignments") + g_lay = QFormLayout(grp); g_lay.setSpacing(6) + + # Collect digital output channels + out_channels = ["— none —"] + for dev in self.registry.all_instances(): + for ch in dev.info.channels: + if ch.channel_id.startswith("do") or "out" in ch.channel_id.lower(): + out_channels.append(f"{dev.info.device_id} / {ch.channel_id} ({ch.name})") + + self._out_combos = {} + for label in ["Pump Power", "Heater", "Motor Enable", "PWM Ch 1"]: + cb = QComboBox(); cb.setObjectName("channelPickerCb") + cb.addItems(out_channels) + g_lay.addRow(f"{label}:", cb) + self._out_combos[label] = cb + + lay.addWidget(grp) + lay.addStretch() + scroll.setWidget(cont) + root = QVBoxLayout(w); root.setContentsMargins(0,0,0,0); root.addWidget(scroll) + return w + + # ── 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 -- cgit v1.2.3