summaryrefslogtreecommitdiff
path: root/ui/windows/settings_window.py
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-04-20 16:55:57 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-04-20 16:55:57 -0600
commitd5acb04b88373d33b038bb59945fb5ab8b4f543b (patch)
tree21c37f2b54258ef20edc1b9b7226327d5f5b1a3a /ui/windows/settings_window.py
parent425ba78ee1f760978b23a09fe8acbbc9b8b5dae4 (diff)
V8
Diffstat (limited to 'ui/windows/settings_window.py')
-rw-r--r--ui/windows/settings_window.py231
1 files changed, 231 insertions, 0 deletions
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