summaryrefslogtreecommitdiff
path: root/ui
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-04-13 15:22:56 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-04-13 15:22:56 -0600
commit2ed9d37da7b27d25173535550fb92702225ac14e (patch)
treed6ff360a656e577629d3cefdaa1ae02af7255864 /ui
parent8d6acf3a8ea4b37f86b321dbf430be5be01b1267 (diff)
Removed QtPy5 snippet and fixed directories
Diffstat (limited to 'ui')
-rw-r--r--ui/__init__.py1
-rw-r--r--ui/__pycache__/__init__.cpython-314.pycbin0 -> 153 bytes
-rw-r--r--ui/__pycache__/add_device_dialog.cpython-314.pycbin0 -> 5942 bytes
-rw-r--r--ui/__pycache__/alarm_panel.cpython-314.pycbin0 -> 6113 bytes
-rw-r--r--ui/__pycache__/config_dialog.cpython-314.pycbin0 -> 7331 bytes
-rw-r--r--ui/__pycache__/device_panel.cpython-314.pycbin0 -> 10002 bytes
-rw-r--r--ui/__pycache__/main_window.cpython-314.pycbin0 -> 15798 bytes
-rw-r--r--ui/__pycache__/readout_panel.cpython-314.pycbin0 -> 9028 bytes
-rw-r--r--ui/__pycache__/strip_chart.cpython-314.pycbin0 -> 11052 bytes
-rw-r--r--ui/add_device_dialog.py100
-rw-r--r--ui/alarm_panel.py85
-rw-r--r--ui/config_dialog.py111
-rw-r--r--ui/device_panel.py148
-rw-r--r--ui/main_window.py214
-rw-r--r--ui/readout_panel.py122
-rw-r--r--ui/strip_chart.py186
-rw-r--r--ui/style.qss378
17 files changed, 1345 insertions, 0 deletions
diff --git a/ui/__init__.py b/ui/__init__.py
new file mode 100644
index 0000000..dfde25c
--- /dev/null
+++ b/ui/__init__.py
@@ -0,0 +1 @@
+# ui/__init__.py
diff --git a/ui/__pycache__/__init__.cpython-314.pyc b/ui/__pycache__/__init__.cpython-314.pyc
new file mode 100644
index 0000000..6e83590
--- /dev/null
+++ b/ui/__pycache__/__init__.cpython-314.pyc
Binary files differ
diff --git a/ui/__pycache__/add_device_dialog.cpython-314.pyc b/ui/__pycache__/add_device_dialog.cpython-314.pyc
new file mode 100644
index 0000000..280fdbf
--- /dev/null
+++ b/ui/__pycache__/add_device_dialog.cpython-314.pyc
Binary files differ
diff --git a/ui/__pycache__/alarm_panel.cpython-314.pyc b/ui/__pycache__/alarm_panel.cpython-314.pyc
new file mode 100644
index 0000000..a1811f7
--- /dev/null
+++ b/ui/__pycache__/alarm_panel.cpython-314.pyc
Binary files differ
diff --git a/ui/__pycache__/config_dialog.cpython-314.pyc b/ui/__pycache__/config_dialog.cpython-314.pyc
new file mode 100644
index 0000000..8876af3
--- /dev/null
+++ b/ui/__pycache__/config_dialog.cpython-314.pyc
Binary files differ
diff --git a/ui/__pycache__/device_panel.cpython-314.pyc b/ui/__pycache__/device_panel.cpython-314.pyc
new file mode 100644
index 0000000..07eb750
--- /dev/null
+++ b/ui/__pycache__/device_panel.cpython-314.pyc
Binary files differ
diff --git a/ui/__pycache__/main_window.cpython-314.pyc b/ui/__pycache__/main_window.cpython-314.pyc
new file mode 100644
index 0000000..27e4279
--- /dev/null
+++ b/ui/__pycache__/main_window.cpython-314.pyc
Binary files differ
diff --git a/ui/__pycache__/readout_panel.cpython-314.pyc b/ui/__pycache__/readout_panel.cpython-314.pyc
new file mode 100644
index 0000000..9258b10
--- /dev/null
+++ b/ui/__pycache__/readout_panel.cpython-314.pyc
Binary files differ
diff --git a/ui/__pycache__/strip_chart.cpython-314.pyc b/ui/__pycache__/strip_chart.cpython-314.pyc
new file mode 100644
index 0000000..4af6ff5
--- /dev/null
+++ b/ui/__pycache__/strip_chart.cpython-314.pyc
Binary files differ
diff --git a/ui/add_device_dialog.py b/ui/add_device_dialog.py
new file mode 100644
index 0000000..30d0016
--- /dev/null
+++ b/ui/add_device_dialog.py
@@ -0,0 +1,100 @@
+"""
+ui/add_device_dialog.py — Dialog to add a new device at runtime.
+"""
+
+from PyQt6.QtWidgets import (
+ QDialog, QVBoxLayout, QFormLayout, QHBoxLayout,
+ QComboBox, QLineEdit, QSpinBox, QCheckBox,
+ QPushButton, QLabel, QMessageBox,
+)
+
+from devices.device_registry import DeviceRegistry
+from devices.analog_input import AnalogInputDevice
+from devices.digital_io import DigitalIODevice
+from devices.serial_device import SerialDevice
+
+
+# Map display name -> (class, extra_kwargs_defaults)
+_DEVICE_TYPES = {
+ "Analog Input — NI-DAQmx": (AnalogInputDevice, {"backend": "nidaqmx"}),
+ "Analog Input — Arduino": (AnalogInputDevice, {"backend": "arduino"}),
+ "Digital I/O — NI-DAQmx": (DigitalIODevice, {"backend": "nidaqmx"}),
+ "Digital I/O — Arduino": (DigitalIODevice, {"backend": "arduino"}),
+ "Serial / UART": (SerialDevice, {}),
+}
+
+
+class AddDeviceDialog(QDialog):
+ def __init__(self, registry: DeviceRegistry, parent=None):
+ super().__init__(parent)
+ self.registry = registry
+ self.created_device = None
+ self.setWindowTitle("Add Device")
+ self.setMinimumWidth(360)
+ self._build()
+
+ def _build(self):
+ layout = QVBoxLayout(self)
+ form = QFormLayout()
+
+ self._type_cb = QComboBox()
+ self._type_cb.addItems(list(_DEVICE_TYPES.keys()))
+ form.addRow("Device Type:", self._type_cb)
+
+ self._id_edit = QLineEdit()
+ self._id_edit.setPlaceholderText("e.g. ai_1 / ser_0")
+ form.addRow("Device ID:", self._id_edit)
+
+ self._ch_spin = QSpinBox()
+ self._ch_spin.setRange(1, 16)
+ self._ch_spin.setValue(4)
+ form.addRow("# Channels:", self._ch_spin)
+
+ self._sim_chk = QCheckBox("Simulation mode (no hardware required)")
+ self._sim_chk.setChecked(True)
+ form.addRow(self._sim_chk)
+
+ layout.addLayout(form)
+
+ btns = QHBoxLayout()
+ btns.addStretch()
+ cancel = QPushButton("Cancel")
+ cancel.clicked.connect(self.reject)
+ add = QPushButton("Add Device")
+ add.setDefault(True)
+ add.setObjectName("applyButton")
+ add.clicked.connect(self._on_add)
+ btns.addWidget(cancel)
+ btns.addWidget(add)
+ layout.addLayout(btns)
+
+ def _on_add(self):
+ label = self._type_cb.currentText()
+ cls, kw = _DEVICE_TYPES[label]
+ dev_id = self._id_edit.text().strip()
+
+ if not dev_id:
+ base = kw.get("backend", "dev")
+ existing = {d.info.device_id for d in self.registry.all_instances()}
+ for i in range(100):
+ candidate = f"{base}_{i}"
+ if candidate not in existing:
+ dev_id = candidate
+ break
+
+ if self.registry.get_instance(dev_id):
+ QMessageBox.warning(self, "Duplicate ID",
+ f"A device with ID '{dev_id}' already exists.")
+ return
+
+ try:
+ dev = cls(
+ device_id=dev_id,
+ num_channels=self._ch_spin.value(),
+ simulate=self._sim_chk.isChecked(),
+ **kw,
+ )
+ self.created_device = dev
+ self.accept()
+ except Exception as e:
+ QMessageBox.critical(self, "Error creating device", str(e))
diff --git a/ui/alarm_panel.py b/ui/alarm_panel.py
new file mode 100644
index 0000000..1270aff
--- /dev/null
+++ b/ui/alarm_panel.py
@@ -0,0 +1,85 @@
+"""
+ui/alarm_panel.py — Alarm event log.
+"""
+
+from datetime import datetime
+from PyQt6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel,
+ QScrollArea, QFrame, QPushButton,
+)
+from PyQt6.QtCore import Qt
+
+
+class AlarmRow(QFrame):
+ def __init__(self, dev_id: str, ch_id: str, value: float, kind: str):
+ super().__init__()
+ self.setObjectName("alarmEntry")
+ color = "#ef4444" if kind == "high" else "#f59e0b"
+ arrow = "▲" if kind == "high" else "▼"
+ self.setStyleSheet(f"QFrame#alarmEntry {{ border-left: 3px solid {color}; }}")
+
+ ts_str = datetime.now().strftime("%H:%M:%S.%f")[:11]
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(8, 3, 8, 3)
+
+ msg = QLabel(f"{arrow} {dev_id} / {ch_id} = {value:.4f} [{kind.upper()}]")
+ msg.setObjectName("alarmMsg")
+ ts = QLabel(ts_str)
+ ts.setObjectName("alarmTs")
+
+ layout.addWidget(msg, 1)
+ layout.addWidget(ts)
+
+
+class AlarmPanel(QWidget):
+ def __init__(self):
+ super().__init__()
+ self._count = 0
+ self._build()
+
+ def _build(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ hdr_w = QWidget()
+ hdr_w.setObjectName("alarmHeaderWidget")
+ hdr_lay = QHBoxLayout(hdr_w)
+ hdr_lay.setContentsMargins(0, 0, 4, 0)
+ hdr = QLabel(" ALARMS")
+ hdr.setObjectName("panelHeader")
+ hdr.setMinimumHeight(28)
+ hdr_lay.addWidget(hdr, 1)
+ clr = QPushButton("Clear")
+ clr.setObjectName("clearAlarmsBtn")
+ clr.clicked.connect(self.clear)
+ hdr_lay.addWidget(clr)
+ layout.addWidget(hdr_w)
+
+ self._scroll = QScrollArea()
+ self._scroll.setWidgetResizable(True)
+ self._container = QWidget()
+ self._inner = QVBoxLayout(self._container)
+ self._inner.setContentsMargins(4, 4, 4, 4)
+ self._inner.setSpacing(2)
+ self._inner.addStretch()
+ self._scroll.setWidget(self._container)
+ layout.addWidget(self._scroll)
+
+ def add_alarm(self, dev_id: str, ch_id: str, value: float, kind: str):
+ row = AlarmRow(dev_id, ch_id, value, kind)
+ self._inner.insertWidget(0, row)
+ self._count += 1
+ # Cap at 300 entries
+ if self._count > 300:
+ item = self._inner.takeAt(self._inner.count() - 2)
+ if item and item.widget():
+ item.widget().deleteLater()
+ self._count -= 1
+
+ def clear(self):
+ while self._inner.count() > 1:
+ item = self._inner.takeAt(0)
+ if item and item.widget():
+ item.widget().deleteLater()
+ self._count = 0
diff --git a/ui/config_dialog.py b/ui/config_dialog.py
new file mode 100644
index 0000000..7d87772
--- /dev/null
+++ b/ui/config_dialog.py
@@ -0,0 +1,111 @@
+"""
+ui/config_dialog.py — Device configuration dialog (tabbed).
+"""
+
+from PyQt6.QtWidgets import (
+ QDialog, QVBoxLayout, QHBoxLayout, QTabWidget,
+ QWidget, QFormLayout, QGroupBox, QScrollArea,
+ QLabel, QLineEdit, QDoubleSpinBox, QCheckBox,
+ QPushButton,
+)
+from PyQt6.QtCore import Qt
+
+
+class DeviceConfigDialog(QDialog):
+ def __init__(self, device, parent=None):
+ super().__init__(parent)
+ self.device = device
+ self.setWindowTitle(f"Configure — {device.info.name} [{device.info.device_id}]")
+ self.setMinimumSize(520, 460)
+ self._build()
+
+ def _build(self):
+ layout = QVBoxLayout(self)
+ layout.setSpacing(8)
+
+ tabs = QTabWidget()
+
+ # ── Tab 1: Device-specific widget ────────────────────────────
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setWidget(self.device.get_config_widget())
+ tabs.addTab(scroll, "Hardware / Backend")
+
+ # ── Tab 2: Channel settings ───────────────────────────────────
+ tabs.addTab(self._channel_tab(), "Channels & Alarms")
+
+ # ── Tab 3: Device info ────────────────────────────────────────
+ tabs.addTab(self._info_tab(), "Info")
+
+ layout.addWidget(tabs)
+
+ btn_row = QHBoxLayout()
+ btn_row.addStretch()
+ close_btn = QPushButton("Close")
+ close_btn.setDefault(True)
+ close_btn.clicked.connect(self.accept)
+ btn_row.addWidget(close_btn)
+ layout.addLayout(btn_row)
+
+ def _channel_tab(self):
+ w = QScrollArea()
+ w.setWidgetResizable(True)
+ container = QWidget()
+ layout = QVBoxLayout(container)
+
+ for ch in self.device.info.channels:
+ grp = QGroupBox(f"{ch.channel_id} — {ch.name}")
+ form = QFormLayout(grp)
+
+ name_e = QLineEdit(ch.name)
+ unit_e = QLineEdit(ch.unit)
+ en_chk = QCheckBox()
+ en_chk.setChecked(ch.enabled)
+
+ lo = QDoubleSpinBox(); lo.setRange(-1e9, 1e9); lo.setValue(ch.alarm_low or 0.0)
+ hi = QDoubleSpinBox(); hi.setRange(-1e9, 1e9); hi.setValue(ch.alarm_high or 100.0)
+
+ form.addRow("Name:", name_e)
+ form.addRow("Unit:", unit_e)
+ form.addRow("Enabled:", en_chk)
+ form.addRow("Alarm Low:", lo)
+ form.addRow("Alarm High:", hi)
+
+ apply = QPushButton("Apply")
+ apply.setObjectName("applyButton")
+
+ def _make_apply(c, ne, ue, ec, ls, hs):
+ def _do():
+ c.name = ne.text()
+ c.unit = ue.text()
+ c.enabled = ec.isChecked()
+ c.alarm_low = ls.value()
+ c.alarm_high = hs.value()
+ return _do
+
+ apply.clicked.connect(_make_apply(ch, name_e, unit_e, en_chk, lo, hi))
+ form.addRow(apply)
+ layout.addWidget(grp)
+
+ layout.addStretch()
+ w.setWidget(container)
+ return w
+
+ def _info_tab(self):
+ w = QWidget()
+ form = QFormLayout(w)
+ info = self.device.info
+
+ def _ro(v):
+ e = QLineEdit(str(v)); e.setReadOnly(True); return e
+
+ form.addRow("Device ID:", _ro(info.device_id))
+ form.addRow("Name:", _ro(info.name))
+ form.addRow("Type:", _ro(info.device_type))
+ form.addRow("Description:", _ro(info.description))
+ form.addRow("Manufacturer:", _ro(info.manufacturer))
+ form.addRow("Model:", _ro(info.model))
+ form.addRow("Version:", _ro(info.version))
+ form.addRow("Status:", _ro(self.device.status.value))
+ form.addRow("Channels:", _ro(len(info.channels)))
+ return w
diff --git a/ui/device_panel.py b/ui/device_panel.py
new file mode 100644
index 0000000..932f536
--- /dev/null
+++ b/ui/device_panel.py
@@ -0,0 +1,148 @@
+"""
+ui/device_panel.py — Left sidebar: device list with status indicators.
+"""
+
+from PyQt6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel,
+ QPushButton, QScrollArea, QFrame, QSizePolicy,
+)
+from PyQt6.QtCore import Qt, pyqtSignal, QTimer
+from devices.base_device import DeviceStatus
+from devices.device_registry import DeviceRegistry
+from core.acquisition import AcquisitionEngine
+
+
+_STATUS_STYLE = {
+ DeviceStatus.CONNECTED: "color:#22c55e;",
+ DeviceStatus.SIMULATED: "color:#3b82f6;",
+ DeviceStatus.DISCONNECTED: "color:#475569;",
+ DeviceStatus.CONNECTING: "color:#f59e0b;",
+ DeviceStatus.ERROR: "color:#ef4444;",
+}
+_STATUS_LABEL = {
+ DeviceStatus.CONNECTED: "CONNECTED",
+ DeviceStatus.SIMULATED: "SIMULATED",
+ DeviceStatus.DISCONNECTED: "OFFLINE",
+ DeviceStatus.CONNECTING: "CONNECTING",
+ DeviceStatus.ERROR: "ERROR",
+}
+
+
+class DeviceCard(QFrame):
+ config_requested = pyqtSignal(str)
+
+ def __init__(self, device):
+ super().__init__()
+ self.device = device
+ self.setObjectName("deviceCard")
+ self._build()
+
+ def _build(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(10, 10, 10, 10)
+ layout.setSpacing(5)
+
+ # Header row: icon + name + status dot
+ hdr = QHBoxLayout()
+ icon_lbl = QLabel(self.device.info.icon)
+ icon_lbl.setObjectName("deviceIcon")
+ hdr.addWidget(icon_lbl)
+
+ name_lbl = QLabel(self.device.info.name)
+ name_lbl.setObjectName("deviceName")
+ hdr.addWidget(name_lbl, 1)
+
+ self._dot = QLabel("●")
+ self._dot.setStyleSheet(_STATUS_STYLE.get(self.device.status, "color:#475569;") + " font-size:10px;")
+ hdr.addWidget(self._dot)
+ layout.addLayout(hdr)
+
+ # Device ID
+ id_lbl = QLabel(self.device.info.device_id)
+ id_lbl.setObjectName("deviceSub")
+ layout.addWidget(id_lbl)
+
+ # Status text
+ self._status_lbl = QLabel(_STATUS_LABEL.get(self.device.status, "UNKNOWN"))
+ style = _STATUS_STYLE.get(self.device.status, "color:#475569;")
+ self._status_lbl.setStyleSheet(
+ style + " font-family:'IBM Plex Mono',monospace; font-size:10px; font-weight:700; letter-spacing:1px;"
+ )
+ layout.addWidget(self._status_lbl)
+
+ # Channel count
+ n = sum(1 for c in self.device.info.channels if c.enabled)
+ ch_lbl = QLabel(f"{n} ch · {self.device.info.device_type}")
+ ch_lbl.setObjectName("deviceChannelCount")
+ layout.addWidget(ch_lbl)
+
+ # Config button
+ cfg = QPushButton("Configure")
+ cfg.setObjectName("configButton")
+ cfg.clicked.connect(lambda: self.config_requested.emit(self.device.info.device_id))
+ layout.addWidget(cfg)
+
+ def refresh(self):
+ dot_style = _STATUS_STYLE.get(self.device.status, "color:#475569;") + " font-size:10px;"
+ label_style = _STATUS_STYLE.get(self.device.status, "color:#475569;") + \
+ " font-family:'IBM Plex Mono',monospace; font-size:10px; font-weight:700; letter-spacing:1px;"
+ self._dot.setStyleSheet(dot_style)
+ self._status_lbl.setText(_STATUS_LABEL.get(self.device.status, "UNKNOWN"))
+ self._status_lbl.setStyleSheet(label_style)
+
+
+class DevicePanel(QWidget):
+ config_requested = pyqtSignal(str)
+
+ def __init__(self, registry: DeviceRegistry, engine: AcquisitionEngine):
+ super().__init__()
+ self.registry = registry
+ self.engine = engine
+ self._cards = {}
+ self._build()
+
+ # Refresh status every 2 s
+ self._timer = QTimer(self)
+ self._timer.setInterval(2000)
+ self._timer.timeout.connect(self._refresh_status)
+ self._timer.start()
+
+ def _build(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ hdr = QLabel(" DEVICES")
+ hdr.setObjectName("panelHeader")
+ hdr.setMinimumHeight(28)
+ layout.addWidget(hdr)
+
+ scroll = QScrollArea()
+ scroll.setObjectName("deviceScroll")
+ scroll.setWidgetResizable(True)
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+
+ self._container = QWidget()
+ self._inner = QVBoxLayout(self._container)
+ self._inner.setContentsMargins(8, 8, 8, 8)
+ self._inner.setSpacing(8)
+ self._inner.addStretch()
+
+ scroll.setWidget(self._container)
+ layout.addWidget(scroll)
+
+ def refresh(self):
+ for card in self._cards.values():
+ self._inner.removeWidget(card)
+ card.deleteLater()
+ self._cards.clear()
+
+ for dev in self.registry.all_instances():
+ card = DeviceCard(dev)
+ card.config_requested.connect(self.config_requested.emit)
+ self._inner.insertWidget(self._inner.count() - 1, card)
+ self._cards[dev.info.device_id] = card
+
+ def _refresh_status(self):
+ for dev_id, card in self._cards.items():
+ card.refresh()
diff --git a/ui/main_window.py b/ui/main_window.py
new file mode 100644
index 0000000..d329571
--- /dev/null
+++ b/ui/main_window.py
@@ -0,0 +1,214 @@
+"""
+ui/main_window.py — Main application window.
+
+Layout:
+ ┌──────────────────────────────────────────────────────────┐
+ │ TOOLBAR [▶ RUN] [⬤ LOG] [+ Device] 00:00:00 │
+ ├────────────┬───────────────────────────┬─────────────────┤
+ │ DEVICES │ STRIP CHART (pyqtgraph) │ CHANNELS │
+ │ (left) │ │ (readouts) │
+ │ │ ├─────────────────┤
+ │ │ │ ALARMS │
+ └────────────┴───────────────────────────┴─────────────────┘
+ │ status bar │
+ └──────────────────────────────────────────────────────────┘
+"""
+
+from PyQt6.QtWidgets import (
+ QMainWindow, QWidget, QHBoxLayout, QVBoxLayout,
+ QSplitter, QStatusBar, QLabel, QPushButton,
+ QToolBar, QSizePolicy, QMessageBox,
+)
+from PyQt6.QtCore import Qt, QTimer, pyqtSlot
+
+from devices.analog_input import AnalogInputDevice
+from devices.digital_io import DigitalIODevice
+from devices.serial_device import SerialDevice
+from devices.device_registry import DeviceRegistry
+from core.acquisition import AcquisitionEngine
+
+from ui.device_panel import DevicePanel
+from ui.strip_chart import StripChartWidget
+from ui.readout_panel import ReadoutPanel
+from ui.alarm_panel import AlarmPanel
+from ui.config_dialog import DeviceConfigDialog
+from ui.add_device_dialog import AddDeviceDialog
+
+
+class MainWindow(QMainWindow):
+ def __init__(self):
+ super().__init__()
+ self.setWindowTitle("LabDAQ — Data Acquisition System")
+ self.setMinimumSize(1360, 820)
+
+ self.registry = DeviceRegistry()
+ self.engine = AcquisitionEngine(poll_interval_ms=100)
+
+ self._elapsed = 0
+ self._running = False
+
+ self._init_demo_devices()
+ self._build_ui()
+ self._connect_signals()
+
+ def _init_demo_devices(self):
+ ai = AnalogInputDevice(device_id="ai_0", num_channels=4, simulate=True, backend="nidaqmx")
+ ai.connect()
+ ard = AnalogInputDevice(device_id="ard_0", num_channels=4, simulate=True, backend="arduino")
+ ard.connect()
+ dio = DigitalIODevice(device_id="dio_0", num_inputs=4, num_outputs=4, simulate=True, backend="nidaqmx")
+ dio.connect()
+ ser = SerialDevice(device_id="ser_0", num_channels=3, simulate=True)
+ ser.connect()
+ for dev in [ai, ard, dio, ser]:
+ self.registry.add_instance(dev)
+ self.engine.add_device(dev)
+
+ def _build_ui(self):
+ tb = QToolBar("Main")
+ tb.setObjectName("mainToolbar")
+ tb.setMovable(False)
+ self.addToolBar(tb)
+
+ self._run_btn = QPushButton("▶ RUN")
+ self._run_btn.setObjectName("runButton")
+ self._run_btn.setCheckable(True)
+ self._run_btn.clicked.connect(self._toggle_run)
+ tb.addWidget(self._run_btn)
+ tb.addSeparator()
+
+ self._log_btn = QPushButton("⬤ LOG")
+ self._log_btn.setObjectName("logButton")
+ self._log_btn.setCheckable(True)
+ self._log_btn.setEnabled(False)
+ self._log_btn.clicked.connect(self._toggle_log)
+ tb.addWidget(self._log_btn)
+ tb.addSeparator()
+
+ add_btn = QPushButton("+ Device")
+ add_btn.setObjectName("addDeviceButton")
+ add_btn.clicked.connect(self._add_device)
+ tb.addWidget(add_btn)
+
+ spacer = QWidget()
+ spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
+ tb.addWidget(spacer)
+
+ self._time_lbl = QLabel("00:00:00")
+ self._time_lbl.setObjectName("timeLabel")
+ tb.addWidget(self._time_lbl)
+
+ central = QWidget()
+ self.setCentralWidget(central)
+ root = QHBoxLayout(central)
+ root.setContentsMargins(0, 0, 0, 0)
+ root.setSpacing(0)
+
+ hsplit = QSplitter(Qt.Orientation.Horizontal)
+ hsplit.setHandleWidth(3)
+
+ self._dev_panel = DevicePanel(self.registry, self.engine)
+ self._dev_panel.setMinimumWidth(210)
+ self._dev_panel.setMaximumWidth(300)
+ hsplit.addWidget(self._dev_panel)
+
+ self._chart = StripChartWidget(self.engine, self.registry)
+ hsplit.addWidget(self._chart)
+
+ right_widget = QWidget()
+ right_widget.setMinimumWidth(230)
+ right_widget.setMaximumWidth(320)
+ right_lay = QVBoxLayout(right_widget)
+ right_lay.setContentsMargins(0, 0, 0, 0)
+ right_lay.setSpacing(0)
+ vsplit = QSplitter(Qt.Orientation.Vertical)
+ vsplit.setHandleWidth(3)
+ self._readout_panel = ReadoutPanel(self.registry)
+ self._alarm_panel = AlarmPanel()
+ vsplit.addWidget(self._readout_panel)
+ vsplit.addWidget(self._alarm_panel)
+ vsplit.setSizes([500, 300])
+ right_lay.addWidget(vsplit)
+ hsplit.addWidget(right_widget)
+
+ hsplit.setSizes([230, 880, 260])
+ root.addWidget(hsplit)
+
+ sb = QStatusBar()
+ self.setStatusBar(sb)
+ self._status_lbl = QLabel("Ready — simulation mode active")
+ sb.addWidget(self._status_lbl)
+ self._log_lbl = QLabel("")
+ sb.addPermanentWidget(self._log_lbl)
+
+ self._clock = QTimer(self)
+ self._clock.setInterval(1000)
+ self._clock.timeout.connect(self._tick)
+
+ def _connect_signals(self):
+ self.engine.alarm_triggered.connect(self._on_alarm)
+ self.engine.new_data.connect(self._readout_panel.on_new_data)
+ self.engine.new_data.connect(self._chart.on_new_data)
+ self.engine.log_started.connect(lambda p: self._log_lbl.setText(f"● LOG {p}"))
+ self.engine.log_stopped.connect(lambda p: self._log_lbl.setText(f"✓ Saved {p}"))
+ self._dev_panel.config_requested.connect(self._open_config)
+
+ def _toggle_run(self, checked: bool):
+ if checked:
+ self.engine.start()
+ self._run_btn.setText("⏹ STOP")
+ self._log_btn.setEnabled(True)
+ self._clock.start()
+ self._status_lbl.setText("Acquiring…")
+ self._running = True
+ else:
+ self.engine.stop()
+ self._run_btn.setText("▶ RUN")
+ if self._log_btn.isChecked():
+ self._log_btn.setChecked(False)
+ self._log_btn.setEnabled(False)
+ self._clock.stop()
+ self._status_lbl.setText("Stopped")
+ self._running = False
+
+ def _toggle_log(self, checked: bool):
+ if checked:
+ path = self.engine.start_logging()
+ self._log_btn.setText("⏹ LOGGING")
+ self._status_lbl.setText(f"Logging → {path}")
+ else:
+ self.engine.stop_logging()
+ self._log_btn.setText("⬤ LOG")
+
+ def _add_device(self):
+ dlg = AddDeviceDialog(self.registry, self)
+ if dlg.exec():
+ dev = dlg.created_device
+ if dev:
+ dev.connect()
+ self.registry.add_instance(dev)
+ self.engine.add_device(dev)
+ self._dev_panel.refresh()
+ self._readout_panel.refresh()
+ self._chart.refresh()
+ self._status_lbl.setText(f"Added device: {dev.info.device_id}")
+
+ def _open_config(self, device_id: str):
+ dev = self.registry.get_instance(device_id)
+ if dev:
+ DeviceConfigDialog(dev, self).exec()
+
+ @pyqtSlot(str, str, float, str)
+ def _on_alarm(self, dev_id: str, ch_id: str, value: float, kind: str):
+ self._alarm_panel.add_alarm(dev_id, ch_id, value, kind)
+
+ def _tick(self):
+ self._elapsed += 1
+ h = self._elapsed // 3600
+ m = (self._elapsed % 3600) // 60
+ s = self._elapsed % 60
+ self._time_lbl.setText(f"{h:02d}:{m:02d}:{s:02d}")
+
+ def closeEvent(self, event):
+ self.engine.stop()
+ event.accept()
diff --git a/ui/readout_panel.py b/ui/readout_panel.py
new file mode 100644
index 0000000..ef04e18
--- /dev/null
+++ b/ui/readout_panel.py
@@ -0,0 +1,122 @@
+"""
+ui/readout_panel.py — Right-side numeric readout panel.
+"""
+
+from PyQt6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel,
+ QScrollArea, QFrame, QProgressBar,
+)
+from PyQt6.QtCore import Qt, pyqtSlot
+from devices.device_registry import DeviceRegistry
+
+
+class ChannelReadout(QFrame):
+ def __init__(self, ch_config):
+ super().__init__()
+ self.ch = ch_config
+ self.setObjectName("channelReadout")
+ self._alarm = False
+ self._build()
+
+ def _build(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(8, 6, 8, 6)
+ layout.setSpacing(2)
+
+ top = QHBoxLayout()
+ self._name = QLabel(self.ch.name)
+ self._name.setObjectName("readoutName")
+ top.addWidget(self._name, 1)
+
+ self._val = QLabel("— — —")
+ self._val.setObjectName("readoutValue")
+ self._val.setStyleSheet(f"color:{self.ch.color};")
+ top.addWidget(self._val)
+
+ unit = QLabel(f" {self.ch.unit}")
+ unit.setObjectName("readoutUnit")
+ top.addWidget(unit)
+ layout.addLayout(top)
+
+ self._bar = QProgressBar()
+ self._bar.setObjectName("readoutBar")
+ self._bar.setRange(0, 1000)
+ self._bar.setValue(500)
+ self._bar.setTextVisible(False)
+ self._bar.setMaximumHeight(3)
+ self._bar.setStyleSheet(
+ f"QProgressBar::chunk {{ background:{self.ch.color}; border-radius:1px; }}"
+ )
+ layout.addWidget(self._bar)
+
+ def update_value(self, v: float):
+ self._val.setText(f"{v:>10.4f}".strip())
+ rng = self.ch.max_value - self.ch.min_value
+ norm = int(((v - self.ch.min_value) / rng) * 1000) if rng else 500
+ self._bar.setValue(max(0, min(1000, norm)))
+
+ alarm = (
+ (self.ch.alarm_low is not None and v < self.ch.alarm_low) or
+ (self.ch.alarm_high is not None and v > self.ch.alarm_high)
+ )
+ if alarm != self._alarm:
+ self._alarm = alarm
+ self.setProperty("alarm", alarm)
+ self.style().unpolish(self)
+ self.style().polish(self)
+
+
+class ReadoutPanel(QWidget):
+ def __init__(self, registry: DeviceRegistry):
+ super().__init__()
+ self.registry = registry
+ self._readouts = {} # (dev_id, ch_id) -> ChannelReadout
+ self._build()
+ self.refresh()
+
+ def _build(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ hdr = QLabel(" CHANNELS")
+ hdr.setObjectName("panelHeader")
+ hdr.setMinimumHeight(28)
+ layout.addWidget(hdr)
+
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+
+ self._container = QWidget()
+ self._inner = QVBoxLayout(self._container)
+ self._inner.setContentsMargins(6, 6, 6, 6)
+ self._inner.setSpacing(4)
+ self._inner.addStretch()
+
+ scroll.setWidget(self._container)
+ layout.addWidget(scroll)
+
+ def refresh(self):
+ for w in self._readouts.values():
+ w.deleteLater()
+ self._readouts.clear()
+ # Clear layout
+ while self._inner.count() > 1:
+ item = self._inner.takeAt(0)
+ if item and item.widget():
+ item.widget().deleteLater()
+
+ for dev in self.registry.all_instances():
+ for ch in dev.info.channels:
+ if not ch.enabled:
+ continue
+ ro = ChannelReadout(ch)
+ self._inner.insertWidget(self._inner.count() - 1, ro)
+ self._readouts[(dev.info.device_id, ch.channel_id)] = ro
+
+ @pyqtSlot(str, str, float, float)
+ def on_new_data(self, dev_id: str, ch_id: str, _ts: float, value: float):
+ ro = self._readouts.get((dev_id, ch_id))
+ if ro:
+ ro.update_value(value)
diff --git a/ui/strip_chart.py b/ui/strip_chart.py
new file mode 100644
index 0000000..afc7cf7
--- /dev/null
+++ b/ui/strip_chart.py
@@ -0,0 +1,186 @@
+"""
+ui/strip_chart.py
+
+Live scrolling strip chart. One pyqtgraph plot per device,
+all time-axes linked. Per-channel colored traces with legend.
+"""
+
+import numpy as np
+from typing import Dict
+
+from PyQt6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel,
+ QDoubleSpinBox, QPushButton, QSizePolicy, QComboBox,
+)
+from PyQt6.QtCore import Qt, pyqtSlot
+
+try:
+ import pyqtgraph as pg
+ pg.setConfigOptions(antialias=True, background="#0b0e13", foreground="#475569")
+ _HAS_PG = True
+except ImportError:
+ _HAS_PG = False
+
+from devices.device_registry import DeviceRegistry
+from core.acquisition import AcquisitionEngine
+
+
+class StripChartWidget(QWidget):
+ def __init__(self, engine: AcquisitionEngine, registry: DeviceRegistry):
+ super().__init__()
+ self.engine = engine
+ self.registry = registry
+ self._window = 30.0
+ self._paused = False
+ self._plots: Dict[str, Dict] = {} # dev_id -> {ch_id -> {curve, plot}}
+ self._build()
+ self.refresh()
+
+ # ── Build ────────────────────────────────────────────────────────────
+
+ def _build(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(6, 6, 6, 4)
+ layout.setSpacing(4)
+
+ # Control bar
+ ctrl = QHBoxLayout()
+ ctrl.addWidget(QLabel("Window:"))
+
+ self._win_spin = QDoubleSpinBox()
+ self._win_spin.setRange(1.0, 3600.0)
+ self._win_spin.setValue(self._window)
+ self._win_spin.setSuffix(" s")
+ self._win_spin.valueChanged.connect(self._on_window_changed)
+ ctrl.addWidget(self._win_spin)
+ ctrl.addSpacing(16)
+
+ ctrl.addWidget(QLabel("Y-Scale:"))
+ self._scale_cb = QComboBox()
+ self._scale_cb.addItems(["Auto", "Fixed"])
+ self._scale_cb.currentTextChanged.connect(self._on_scale_changed)
+ ctrl.addWidget(self._scale_cb)
+
+ ctrl.addStretch()
+
+ self._pause_btn = QPushButton("⏸ Pause")
+ self._pause_btn.setCheckable(True)
+ self._pause_btn.setObjectName("pauseButton")
+ self._pause_btn.toggled.connect(self._on_pause)
+ ctrl.addWidget(self._pause_btn)
+
+ layout.addLayout(ctrl)
+
+ if _HAS_PG:
+ self._gw = pg.GraphicsLayoutWidget()
+ self._gw.setSizePolicy(
+ QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
+ )
+ layout.addWidget(self._gw)
+ else:
+ layout.addWidget(QLabel(
+ "⚠ pyqtgraph not installed.\n\npip install pyqtgraph\n\nData is still acquired & logged.",
+ alignment=Qt.AlignmentFlag.AlignCenter,
+ ))
+
+ # ── Refresh (rebuild plots after device list changes) ─────────────────
+
+ def refresh(self):
+ if not _HAS_PG:
+ return
+ self._gw.clear()
+ self._plots.clear()
+
+ devs = self.registry.all_instances()
+ n_devs = len(devs)
+ if n_devs == 0:
+ return
+
+ ref_plot = None # for x-axis linking
+
+ for row, dev in enumerate(devs):
+ plot = self._gw.addPlot(row=row, col=0)
+ plot.setLabel("left", f"{dev.info.icon} {dev.info.name}")
+ plot.showGrid(x=True, y=True, alpha=0.12)
+ plot.getAxis("left").setStyle(tickFont=self._mono_font())
+ plot.getAxis("bottom").setStyle(tickFont=self._mono_font())
+
+ if row < n_devs - 1:
+ plot.getAxis("bottom").setStyle(showValues=False)
+ plot.getAxis("bottom").setHeight(0)
+ else:
+ plot.setLabel("bottom", "Elapsed (s)")
+
+ if ref_plot is not None:
+ plot.setXLink(ref_plot)
+ ref_plot = plot
+
+ legend = plot.addLegend(
+ offset=(5, 5),
+ labelTextColor="#94a3b8",
+ brush=pg.mkBrush("#161d2e"),
+ pen=pg.mkPen("#2a3558"),
+ )
+
+ self._plots[dev.info.device_id] = {}
+ for ch in dev.info.channels:
+ if not ch.enabled:
+ continue
+ pen = pg.mkPen(color=ch.color, width=1.8)
+ curve = plot.plot([], [], pen=pen, name=ch.name)
+ self._plots[dev.info.device_id][ch.channel_id] = {
+ "curve": curve,
+ "plot": plot,
+ }
+
+ @staticmethod
+ def _mono_font():
+ from PyQt6.QtGui import QFont
+ f = QFont("IBM Plex Mono", 8)
+ return f
+
+ # ── Slots ────────────────────────────────────────────────────────────
+
+ @pyqtSlot(str, str, float, float)
+ def on_new_data(self, device_id: str, channel_id: str, timestamp: float, value: float):
+ if self._paused or not _HAS_PG:
+ return
+ dev_plots = self._plots.get(device_id)
+ if dev_plots is None:
+ return
+ entry = dev_plots.get(channel_id)
+ if entry is None:
+ return
+
+ buf = self.engine.get_buffer(device_id, channel_id)
+ if buf is None or len(buf) < 2:
+ return
+
+ ts, vs = buf.window(self._window)
+ if not ts:
+ return
+ ts_arr = np.array(ts, dtype=np.float64)
+ vs_arr = np.array(vs, dtype=np.float64)
+
+ entry["curve"].setData(ts_arr, vs_arr)
+ t_max = ts_arr[-1]
+ t_min = t_max - self._window
+ entry["plot"].setXRange(t_min, t_max, padding=0)
+
+ if self._scale_cb.currentText() == "Auto":
+ entry["plot"].enableAutoRange(axis="y")
+
+ def _on_window_changed(self, v: float):
+ self._window = v
+
+ def _on_pause(self, checked: bool):
+ self._paused = checked
+ self._pause_btn.setText("▶ Resume" if checked else "⏸ Pause")
+
+ def _on_scale_changed(self, text: str):
+ if not _HAS_PG:
+ return
+ if text == "Auto":
+ for dev_plots in self._plots.values():
+ for entry in dev_plots.values():
+ entry["plot"].enableAutoRange(axis="y")
diff --git a/ui/style.qss b/ui/style.qss
new file mode 100644
index 0000000..60415fb
--- /dev/null
+++ b/ui/style.qss
@@ -0,0 +1,378 @@
+/* LabDAQ — Industrial Dark Theme
+ Font stack: IBM Plex Mono (monospace data), IBM Plex Sans (UI labels)
+ Palette:
+ bg-base #0b0e13
+ bg-panel #111620
+ bg-card #161d2e
+ bg-raised #1c2540
+ border #2a3558
+ accent-blue #3b82f6
+ accent-cyan #00d4ff
+ accent-green #22c55e
+ text-primary #e2e8f0
+ text-muted #64748b
+ danger #ef4444
+ warning #f59e0b
+*/
+
+* {
+ font-family: "IBM Plex Sans", "Segoe UI", Tahoma, sans-serif;
+ font-size: 12px;
+ color: #e2e8f0;
+}
+
+QMainWindow, QDialog {
+ background-color: #0b0e13;
+}
+
+/* ── Toolbar ─────────────────────────────────────────────────── */
+QToolBar#mainToolbar {
+ background-color: #0b0e13;
+ border-bottom: 1px solid #2a3558;
+ padding: 4px 8px;
+ spacing: 6px;
+}
+
+QPushButton#runButton {
+ background-color: #166534;
+ color: #dcfce7;
+ border: 1px solid #22c55e;
+ border-radius: 4px;
+ padding: 5px 16px;
+ font-family: "IBM Plex Mono", monospace;
+ font-weight: 600;
+ letter-spacing: 0.5px;
+ min-width: 90px;
+}
+QPushButton#runButton:checked {
+ background-color: #7f1d1d;
+ border-color: #ef4444;
+ color: #fee2e2;
+}
+QPushButton#runButton:hover { background-color: #15803d; }
+QPushButton#runButton:checked:hover { background-color: #991b1b; }
+
+QPushButton#logButton {
+ background-color: #1c2540;
+ color: #94a3b8;
+ border: 1px solid #2a3558;
+ border-radius: 4px;
+ padding: 5px 14px;
+ font-family: "IBM Plex Mono", monospace;
+ min-width: 80px;
+}
+QPushButton#logButton:enabled {
+ color: #e2e8f0;
+ border-color: #3b82f6;
+}
+QPushButton#logButton:checked {
+ background-color: #7c2d12;
+ border-color: #ef4444;
+ color: #fee2e2;
+}
+
+QPushButton#addDeviceButton {
+ background-color: #1e3a5f;
+ color: #93c5fd;
+ border: 1px solid #3b82f6;
+ border-radius: 4px;
+ padding: 5px 14px;
+}
+QPushButton#addDeviceButton:hover {
+ background-color: #1d4ed8;
+ color: #eff6ff;
+}
+
+QLabel#timeLabel {
+ font-family: "IBM Plex Mono", monospace;
+ font-size: 16px;
+ font-weight: 700;
+ color: #00d4ff;
+ letter-spacing: 2px;
+ padding-right: 8px;
+}
+
+/* ── Panels & Headers ────────────────────────────────────────── */
+QLabel#panelHeader {
+ background-color: #0f1521;
+ color: #64748b;
+ font-family: "IBM Plex Mono", monospace;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 2px;
+ padding: 6px 0px;
+ border-bottom: 1px solid #2a3558;
+}
+
+QWidget#alarmHeaderWidget {
+ background-color: #0f1521;
+ border-bottom: 1px solid #2a3558;
+}
+
+/* ── Device Cards ────────────────────────────────────────────── */
+QScrollArea#deviceScroll {
+ background-color: #0b0e13;
+ border: none;
+ border-right: 1px solid #2a3558;
+}
+
+QFrame#deviceCard {
+ background-color: #161d2e;
+ border: 1px solid #2a3558;
+ border-radius: 6px;
+}
+QFrame#deviceCard:hover {
+ border-color: #3b82f6;
+ background-color: #1c2540;
+}
+
+QLabel#deviceIcon {
+ font-size: 18px;
+}
+QLabel#deviceName {
+ font-size: 13px;
+ font-weight: 600;
+ color: #e2e8f0;
+}
+QLabel#deviceSub {
+ font-family: "IBM Plex Mono", monospace;
+ font-size: 10px;
+ color: #64748b;
+}
+QLabel#deviceChannelCount {
+ font-size: 11px;
+ color: #3b82f6;
+}
+
+QPushButton#configButton {
+ background-color: #1c2540;
+ color: #64748b;
+ border: 1px solid #2a3558;
+ border-radius: 3px;
+ padding: 3px 10px;
+ font-size: 11px;
+}
+QPushButton#configButton:hover {
+ background-color: #1e3a5f;
+ color: #93c5fd;
+ border-color: #3b82f6;
+}
+
+/* ── Channel Readouts ────────────────────────────────────────── */
+QFrame#channelReadout {
+ background-color: #161d2e;
+ border: 1px solid #1c2540;
+ border-radius: 4px;
+}
+QFrame#channelReadout[alarm="true"] {
+ border-color: #ef4444;
+ background-color: #200d0d;
+}
+QLabel#readoutName {
+ font-size: 11px;
+ color: #64748b;
+}
+QLabel#readoutValue {
+ font-family: "IBM Plex Mono", monospace;
+ font-size: 15px;
+ font-weight: 700;
+}
+QLabel#readoutUnit {
+ font-family: "IBM Plex Mono", monospace;
+ font-size: 10px;
+ color: #475569;
+}
+QProgressBar#readoutBar {
+ background-color: #1c2540;
+ border: none;
+ border-radius: 2px;
+}
+
+/* ── Alarm Panel ─────────────────────────────────────────────── */
+QFrame#alarmEntry {
+ background-color: #161d2e;
+ border-radius: 3px;
+ border: none;
+}
+QLabel#alarmMsg {
+ font-family: "IBM Plex Mono", monospace;
+ font-size: 11px;
+ color: #e2e8f0;
+}
+QLabel#alarmTs {
+ font-family: "IBM Plex Mono", monospace;
+ font-size: 10px;
+ color: #475569;
+}
+QPushButton#clearAlarmsBtn {
+ background: transparent;
+ color: #64748b;
+ border: none;
+ font-size: 11px;
+ padding: 4px 8px;
+}
+QPushButton#clearAlarmsBtn:hover { color: #ef4444; }
+
+/* ── Strip Chart controls ────────────────────────────────────── */
+QPushButton#pauseButton {
+ background-color: #1c2540;
+ color: #64748b;
+ border: 1px solid #2a3558;
+ border-radius: 4px;
+ padding: 4px 12px;
+}
+QPushButton#pauseButton:checked {
+ background-color: #713f12;
+ border-color: #f59e0b;
+ color: #fef3c7;
+}
+
+/* ── Config / Dialog ─────────────────────────────────────────── */
+QDialog {
+ background-color: #111620;
+}
+QTabWidget::pane {
+ background-color: #111620;
+ border: 1px solid #2a3558;
+ border-radius: 4px;
+}
+QTabBar::tab {
+ background-color: #0b0e13;
+ color: #64748b;
+ border: 1px solid #2a3558;
+ padding: 6px 16px;
+ margin-right: 2px;
+}
+QTabBar::tab:selected {
+ background-color: #1c2540;
+ color: #e2e8f0;
+ border-bottom: 2px solid #3b82f6;
+}
+
+QGroupBox {
+ border: 1px solid #2a3558;
+ border-radius: 4px;
+ margin-top: 12px;
+ padding-top: 8px;
+ color: #64748b;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.5px;
+}
+QGroupBox::title {
+ subcontrol-origin: margin;
+ left: 8px;
+ top: -6px;
+ background-color: #111620;
+ padding: 0 4px;
+}
+
+QLineEdit, QDoubleSpinBox, QSpinBox, QComboBox {
+ background-color: #0b0e13;
+ border: 1px solid #2a3558;
+ border-radius: 3px;
+ padding: 4px 8px;
+ color: #e2e8f0;
+ font-family: "IBM Plex Mono", monospace;
+ min-height: 22px;
+}
+QLineEdit:focus, QDoubleSpinBox:focus, QSpinBox:focus, QComboBox:focus {
+ border-color: #3b82f6;
+}
+QComboBox::drop-down { border: none; width: 20px; }
+QComboBox QAbstractItemView {
+ background-color: #161d2e;
+ border: 1px solid #3b82f6;
+ selection-background-color: #1e3a5f;
+}
+
+QCheckBox {
+ color: #94a3b8;
+ spacing: 6px;
+}
+QCheckBox::indicator {
+ width: 14px; height: 14px;
+ border: 1px solid #2a3558;
+ border-radius: 3px;
+ background-color: #0b0e13;
+}
+QCheckBox::indicator:checked {
+ background-color: #3b82f6;
+ border-color: #3b82f6;
+}
+
+QPushButton#applyButton {
+ background-color: #1e3a5f;
+ color: #93c5fd;
+ border: 1px solid #3b82f6;
+ border-radius: 4px;
+ padding: 6px 20px;
+ font-weight: 600;
+}
+QPushButton#applyButton:hover {
+ background-color: #1d4ed8;
+ color: #eff6ff;
+}
+
+/* ── Digital output toggle ───────────────────────────────────── */
+QPushButton#digitalOutBtn {
+ background-color: #1c2540;
+ color: #64748b;
+ border: 1px solid #2a3558;
+ border-radius: 4px;
+ padding: 4px 14px;
+ font-family: "IBM Plex Mono", monospace;
+ min-width: 50px;
+}
+QPushButton#digitalOutBtn:checked {
+ background-color: #166534;
+ border-color: #22c55e;
+ color: #dcfce7;
+}
+
+/* ── Scrollbars ──────────────────────────────────────────────── */
+QScrollBar:vertical {
+ background: #0b0e13;
+ width: 6px;
+ margin: 0;
+}
+QScrollBar::handle:vertical {
+ background: #2a3558;
+ border-radius: 3px;
+ min-height: 20px;
+}
+QScrollBar::handle:vertical:hover { background: #3b82f6; }
+QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
+QScrollBar:horizontal { height: 6px; background: #0b0e13; }
+QScrollBar::handle:horizontal { background: #2a3558; border-radius: 3px; min-width: 20px; }
+
+/* ── Splitter ────────────────────────────────────────────────── */
+QSplitter::handle {
+ background-color: #2a3558;
+}
+QSplitter::handle:hover {
+ background-color: #3b82f6;
+}
+
+/* ── Status bar ──────────────────────────────────────────────── */
+QStatusBar {
+ background-color: #0b0e13;
+ border-top: 1px solid #2a3558;
+ color: #475569;
+ font-family: "IBM Plex Mono", monospace;
+ font-size: 11px;
+}
+
+/* ── General button default ──────────────────────────────────── */
+QPushButton {
+ background-color: #1c2540;
+ color: #94a3b8;
+ border: 1px solid #2a3558;
+ border-radius: 4px;
+ padding: 5px 12px;
+}
+QPushButton:hover { background-color: #1e3a5f; color: #e2e8f0; }
+QPushButton:pressed { background-color: #172554; }
+QPushButton:disabled { color: #334155; border-color: #1c2540; }
+
+QLabel { color: #94a3b8; }