""" ui/main_window.py Toolbar (left→right): [▶ RUN] [⬤ LOG] | [⊞ Devices] | [⚗ Signals] | [📐 Plot] | [⚙ Settings] spacer | timer | [📁 File] """ from PyQt6.QtWidgets import ( QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QSplitter, QStatusBar, QLabel, QPushButton, QToolBar, QSizePolicy, QApplication, QFrame, QAbstractSpinBox, QComboBox, ) from PyQt6.QtCore import Qt, QTimer, QObject, QEvent, pyqtSlot import os class _WheelBlocker(QObject): """App-level event filter: blocks accidental scroll-wheel changes on spin boxes, combo boxes and sliders that don't have keyboard focus.""" def eventFilter(self, obj, event): if event.type() == QEvent.Type.Wheel: if isinstance(obj, (QAbstractSpinBox, QComboBox)): if not obj.hasFocus(): event.ignore() return True return super().eventFilter(obj, event) 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 core.signal_processor import SignalProcessor from core.profile import Profile, ProfileManager from ui.control_panel import ControlPanel from ui.strip_chart import StripChartWidget from ui.profile_manager_ui import ProfileButton from ui.windows.devices_window import DevicesWindow from ui.windows.signals_window import SignalsWindow from ui.windows.plot_window import PlotWindow, build_default_layout from ui.windows.settings_window import SettingsWindow _DARK_QSS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "style_dark.qss") _LIGHT_QSS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "style_light.qss") class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("LabDAQ") self.setMinimumSize(1000, 640) self.registry = DeviceRegistry() self.engine = AcquisitionEngine(poll_interval_ms=100) self.processor = SignalProcessor() self._settings = dict(SettingsWindow._defaults) self._elapsed = 0 self._win_devices = None self._win_signals = None self._win_plot = None self._win_settings = None self._wheel_blocker = _WheelBlocker(self) QApplication.instance().installEventFilter(self._wheel_blocker) self._init_demo_devices() self._build_ui() self._connect_signals() # ── Demo ────────────────────────────────────────────────────────────── def _init_demo_devices(self): # NOTE: if you have a real Arduino, configure ard_0 and dio_0 to use # the same port — port_registry ensures only one serial connection # is opened, and both devices share it automatically. for dev in [ AnalogInputDevice(device_id="ai_0", num_channels=4, simulate=True, backend="nidaqmx"), AnalogInputDevice(device_id="ard_0", num_channels=1, simulate=True, backend="arduino"), DigitalIODevice (device_id="dio_0", num_inputs=2, num_outputs=4, simulate=True, backend="arduino"), SerialDevice (device_id="ser_0", num_channels=3, simulate=True), ]: dev.connect() self.registry.add_instance(dev) self.engine.add_device(dev) # ── UI ──────────────────────────────────────────────────────────────── def _build_ui(self): tb = QToolBar(); tb.setObjectName("mainToolbar"); tb.setMovable(False) self.addToolBar(tb) def _sep(): s = QFrame(); s.setFrameShape(QFrame.Shape.VLine) s.setObjectName("toolbarSep") s.setFixedWidth(6); return s # ── 📁 File button (profiles) ───────────────────────────────────── self._file_btn = ProfileButton( on_new=self._profile_new, get_profile=self._profile_capture, apply_profile=self._profile_apply, ) tb.addWidget(self._file_btn) tb.addWidget(_sep()) 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) 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) self._clear_btn = QPushButton("⌫ Clear") self._clear_btn.setObjectName("toolbarSectionBtn") self._clear_btn.setToolTip("Clear plot history") self._clear_btn.clicked.connect(self._clear_history) tb.addWidget(self._clear_btn) tb.addWidget(_sep()) dev_btn = QPushButton("⊞ Devices") dev_btn.setObjectName("toolbarSectionBtn"); dev_btn.setCheckable(True) dev_btn.clicked.connect(lambda c: self._toggle_win("devices", c, dev_btn)) tb.addWidget(dev_btn); self._btn_devices = dev_btn sig_btn = QPushButton("⚗ Signals") sig_btn.setObjectName("toolbarSectionBtn"); sig_btn.setCheckable(True) sig_btn.clicked.connect(lambda c: self._toggle_win("signals", c, sig_btn)) tb.addWidget(sig_btn); self._btn_signals = sig_btn plot_btn = QPushButton("📐 Plot") plot_btn.setObjectName("toolbarSectionBtn"); plot_btn.setCheckable(True) plot_btn.clicked.connect(lambda c: self._toggle_win("plot", c, plot_btn)) tb.addWidget(plot_btn); self._btn_plot = plot_btn tb.addWidget(_sep()) # Spacer + clock 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) tb.addWidget(_sep()) set_btn = QPushButton("⚙ Settings") set_btn.setObjectName("toolbarSectionBtn"); set_btn.setCheckable(True) set_btn.clicked.connect(lambda c: self._toggle_win("settings", c, set_btn)) tb.addWidget(set_btn); self._btn_settings = set_btn # ── Central ─────────────────────────────────────────────────────── 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._ctrl = ControlPanel(self.registry) self._ctrl.setMinimumWidth(200); self._ctrl.setMaximumWidth(340) self._ctrl._add_demo_widgets() hsplit.addWidget(self._ctrl) self._chart = StripChartWidget(self.engine, self.registry, self.processor) hsplit.addWidget(self._chart) hsplit.setSizes([260, 1000]) root.addWidget(hsplit) sb = QStatusBar(); self.setStatusBar(sb) self._status = QLabel("Ready"); sb.addWidget(self._status) 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.new_data.connect(self.processor.on_raw_data) self.processor.processed_data.connect(self._chart.on_new_data) self.engine.log_started.connect(lambda p: self._log_lbl.setText(f"● {p}")) self.engine.log_stopped.connect(lambda p: self._log_lbl.setText(f"✓ {p}")) # ── Window management ───────────────────────────────────────────────── def _toggle_win(self, name: str, checked: bool, btn: QPushButton): creators = { "devices": self._open_devices, "signals": self._open_signals, "plot": self._open_plot, "settings": self._open_settings, } wins = { "devices": "_win_devices", "signals": "_win_signals", "plot": "_win_plot", "settings": "_win_settings", } if checked: creators[name]() else: win = getattr(self, wins[name], None) if win: win.hide() def _open_devices(self): if self._win_devices is None: self._win_devices = DevicesWindow(self.registry, self.engine, self) self._win_devices.device_added.connect(self._on_device_added) self._win_devices.device_removed.connect(self._on_device_removed) self._win_devices.device_reconfigured.connect(self._on_device_reconfigured) self._win_devices.closed.connect(lambda: self._btn_devices.setChecked(False)) self._show_win(self._win_devices, "right") def _open_signals(self): if self._win_signals is None: self._win_signals = SignalsWindow(self.registry, self.processor, self) self._win_signals.derived_changed.connect(self._on_derived_changed) self._win_signals.visibility_changed.connect(self._chart.refresh) self._win_signals.closed.connect(lambda: self._btn_signals.setChecked(False)) self._show_win(self._win_signals, "right") def _open_plot(self): if self._win_plot is None: self._win_plot = PlotWindow(self.registry, self.processor, self._chart._cfg, self) self._win_plot.layout_applied.connect(self._chart.apply_layout) self._win_plot.closed.connect(lambda: self._btn_plot.setChecked(False)) else: self._win_plot.cfg = self._chart._cfg self._win_plot._populate() self._show_win(self._win_plot, "below") def _open_settings(self): if self._win_settings is None: self._win_settings = SettingsWindow(self.registry, self.engine, self._settings, self) self._win_settings.theme_changed.connect(self._apply_theme) self._win_settings.settings_changed.connect(self._on_settings) self._win_settings.closed.connect(lambda: self._btn_settings.setChecked(False)) self._show_win(self._win_settings, "right") def _show_win(self, win: QWidget, position: str = "right"): geo = self.geometry() if not win.isVisible(): if position == "right": win.move(geo.right() + 8, geo.top() + 40) else: win.move(geo.left(), geo.bottom() + 8) win.show(); win.raise_(); win.activateWindow() # ── Profile callbacks ───────────────────────────────────────────────── def _profile_new(self): """Reset to a blank slate.""" # Clear devices for dev in list(self.registry.all_instances()): try: dev.disconnect() except Exception: pass self.registry.remove_instance(dev.info.device_id) self.engine.remove_device(dev.info.device_id) # Clear signal pipelines self.processor._pipelines.clear() # Clear derived channels for dc in list(self.processor.get_derived()): self.processor.remove_derived(dc.channel_id) # Clear controls self._ctrl.clear_widgets() # Refresh open windows if self._win_devices: self._win_devices.refresh() if self._win_signals: self._win_signals.refresh_derived() if self._win_plot: self._win_plot.refresh_channels() self._chart.refresh() self._status.setText("New profile — blank slate.") def _profile_capture(self, name: str = "Profile") -> Profile: """Serialise current state into a Profile object.""" return ProfileManager.capture( registry=self.registry, processor=self.processor, plot_cfg=self._chart._cfg, control_specs=self._ctrl.get_specs(), settings=self._settings, profile_name=name, ) def _profile_apply(self, profile: Profile): """Restore state from a Profile object.""" plot_cfg = ProfileManager.apply( profile=profile, registry=self.registry, processor=self.processor, control_panel=self._ctrl, settings_ref=self._settings, engine=self.engine, ) if plot_cfg: self._chart.apply_layout(plot_cfg) else: self._chart.refresh() # Refresh open windows if self._win_devices: self._win_devices.refresh() if self._win_signals: self._win_signals.refresh_derived() if self._win_plot: self._win_plot.refresh_channels() self._status.setText(f"Profile loaded: {profile.name}") # ── Device / signal events ──────────────────────────────────────────── def _on_device_added(self): self._chart.refresh() if self._win_plot: self._win_plot.refresh_channels() self._status.setText("Device added.") def _on_device_removed(self, dev_id: str): self._chart.refresh() self._status.setText(f"Device '{dev_id}' removed.") def _on_device_reconfigured(self, dev_id: str): self._chart.refresh() if self._win_plot: self._win_plot.refresh_channels() self._status.setText(f"Device '{dev_id}' reconfigured.") def _on_derived_changed(self): self._chart.refresh() if self._win_plot: self._win_plot.refresh_channels() # ── Run / Log ───────────────────────────────────────────────────────── def _toggle_run(self, c: bool): if c: self.engine.start() self._run_btn.setText("⏹ STOP"); self._log_btn.setEnabled(True) self._clock.start(); self._status.setText("Acquiring…") 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.setText("Stopped") def _clear_history(self): self.engine.clear_history() self.processor.clear_history() self._chart.refresh() self._status.setText("History cleared.") def _toggle_log(self, c: bool): if c: p = self.engine.start_logging( os.path.join(self._settings.get("log_dir", "logs"), "")) self._log_btn.setText("⏹ LOGGING") self._status.setText(f"Logging → {p}") else: self.engine.stop_logging(); self._log_btn.setText("⬤ LOG") # ── Theme / settings ────────────────────────────────────────────────── def _apply_theme(self, theme: str): qss_file = _DARK_QSS if theme == "dark" else _LIGHT_QSS if os.path.exists(qss_file): with open(qss_file) as f: QApplication.instance().setStyleSheet(f.read()) self._chart.set_theme(theme) def _on_settings(self, cfg: dict): self._settings.update(cfg) self.engine._interval = cfg.get("poll_ms", 100) / 1000.0 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): for w in (self._win_devices, self._win_signals, self._win_plot, self._win_settings): if w: w.close() self.engine.stop(); event.accept()