""" ui/windows/config_window.py Unified Configuration window — Devices | Channels | Plot in one tabbed window. Channels tab layout (2 columns): Left — signal table (Device / Signal ID / Dir / On / Name / Unit / Min / Max) ⚙ button per row opens that channel's filter/calibration pipeline dialog Right — derived / virtual channels """ from __future__ import annotations from copy import deepcopy from typing import List, Optional, Set, Tuple from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QToolButton, QScrollArea, QFrame, QTabWidget, QTableWidget, QTableWidgetItem, QHeaderView, QCheckBox, QMessageBox, QAbstractItemView, QSplitter, QDoubleSpinBox, QDialog, QDialogButtonBox, ) from PyQt6.QtCore import Qt, pyqtSignal, QTimer from PyQt6.QtGui import QCloseEvent, QColor from devices.device_registry import DeviceRegistry from core.acquisition import AcquisitionEngine from core.signal_processor import SignalProcessor from ui.windows.devices_window import DeviceRow, ChannelsTab from ui.windows.channels_window import PipelineTab, ChannelPipelineBlock from ui.windows.plot_window import ( PlotWindow, LayoutConfig, LayoutCanvas, PaneBlock, build_default_layout, ) # ══════════════════════════════════════════════════════════════════════════════ # Pipeline dialog — per-channel filter/calibration popup # ══════════════════════════════════════════════════════════════════════════════ class _PipelineDialog(QDialog): """Shows one ChannelPipelineBlock inside a dialog for a single signal.""" def __init__(self, dev_id: str, ch_id: str, ch_name: str, unit: str, processor: SignalProcessor, parent=None): super().__init__(parent) self.setWindowTitle(f"Pipeline — {dev_id} / {ch_id}") self.setMinimumWidth(460) self.resize(500, 420) lay = QVBoxLayout(self) lay.setContentsMargins(0, 0, 0, 12) self._block = ChannelPipelineBlock(dev_id, ch_id, ch_name, unit, processor) self._block._toggle() # start expanded lay.addWidget(self._block, 1) btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) btns.rejected.connect(self.accept) lay.addWidget(btns) # ══════════════════════════════════════════════════════════════════════════════ # Enhanced signals table — adds Dir badge and pipeline ⚙ button # ══════════════════════════════════════════════════════════════════════════════ class _SignalsTab(ChannelsTab): """ChannelsTab with Dir (IN/OUT) badge and per-row ⚙ pipeline button.""" _C_DEVICE = 0 _C_CH_ID = 1 _C_DIR = 2 _C_ENABLED = 3 _C_NAME = 4 _C_UNIT = 5 _C_MIN = 6 _C_MAX = 7 _C_PIPE = 8 # ⚙ pipeline button def __init__(self, registry: DeviceRegistry, processor: SignalProcessor): self._processor = processor super().__init__(registry) def _build(self): lay = QVBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) self._table = QTableWidget() self._table.setObjectName("channelTable") self._table.setColumnCount(9) self._table.setHorizontalHeaderLabels( ["Device", "Signal ID", "Dir", "On", "Name", "Unit", "Min", "Max", ""] ) hdr = self._table.horizontalHeader() hdr.setSectionResizeMode(self._C_NAME, QHeaderView.ResizeMode.Stretch) hdr.setSectionResizeMode(self._C_DEVICE, QHeaderView.ResizeMode.ResizeToContents) hdr.setSectionResizeMode(self._C_CH_ID, QHeaderView.ResizeMode.ResizeToContents) hdr.setSectionResizeMode(self._C_DIR, QHeaderView.ResizeMode.ResizeToContents) hdr.setSectionResizeMode(self._C_ENABLED, QHeaderView.ResizeMode.ResizeToContents) hdr.setSectionResizeMode(self._C_PIPE, QHeaderView.ResizeMode.ResizeToContents) self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self._table.setAlternatingRowColors(True) self._table.verticalHeader().setVisible(False) self._table.cellChanged.connect(self._on_cell_changed) lay.addWidget(self._table) def refresh(self): self._table.blockSignals(True) self._table.setRowCount(0) row = 0 for dev in self.registry.all_instances(): for ch in dev.info.channels: self._table.insertRow(row) dev_item = QTableWidgetItem(f"{dev.info.icon} {dev.info.device_id}") dev_item.setFlags(dev_item.flags() & ~Qt.ItemFlag.ItemIsEditable) dev_item.setForeground(QColor("#64748b")) self._table.setItem(row, self._C_DEVICE, dev_item) ch_item = QTableWidgetItem(ch.channel_id) ch_item.setFlags(ch_item.flags() & ~Qt.ItemFlag.ItemIsEditable) ch_item.setForeground(QColor(ch.color)) self._table.setItem(row, self._C_CH_ID, ch_item) is_out = getattr(ch, "is_output", False) dir_item = QTableWidgetItem("OUT" if is_out else "IN") dir_item.setFlags(dir_item.flags() & ~Qt.ItemFlag.ItemIsEditable) dir_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) dir_item.setForeground(QColor("#f59e0b") if is_out else QColor("#22c55e")) self._table.setItem(row, self._C_DIR, dir_item) # Enable toggle — disabled for output channels chk_w = QWidget() chk_l = QHBoxLayout(chk_w) chk_l.setContentsMargins(0, 0, 0, 0) chk_l.setAlignment(Qt.AlignmentFlag.AlignCenter) chk = QCheckBox() chk.setChecked(True if is_out else ch.enabled) if is_out: chk.setEnabled(False) else: dev_id_cap = dev.info.device_id ch_id_cap = ch.channel_id chk.toggled.connect( lambda checked, d=dev_id_cap, c=ch_id_cap: self._on_enabled(d, c, checked) ) chk_l.addWidget(chk) self._table.setCellWidget(row, self._C_ENABLED, chk_w) name_item = QTableWidgetItem(ch.name) name_item.setData(Qt.ItemDataRole.UserRole, (dev.info.device_id, ch.channel_id)) self._table.setItem(row, self._C_NAME, name_item) self._table.setItem(row, self._C_UNIT, QTableWidgetItem(ch.unit)) self._table.setItem(row, self._C_MIN, QTableWidgetItem(str(ch.min_value))) self._table.setItem(row, self._C_MAX, QTableWidgetItem(str(ch.max_value))) # Pipeline ⚙ button pipe_w = QWidget() pipe_l = QHBoxLayout(pipe_w) pipe_l.setContentsMargins(2, 0, 2, 0) pipe_l.setAlignment(Qt.AlignmentFlag.AlignCenter) pipe_btn = QToolButton() pipe_btn.setText("⚙") pipe_btn.setObjectName("traceRemoveBtn") pipe_btn.setFixedSize(22, 22) pipe_btn.setToolTip("Configure filters / calibration") d_cap, c_cap, n_cap, u_cap = ( dev.info.device_id, ch.channel_id, ch.name, ch.unit ) pipe_btn.clicked.connect( lambda _=False, d=d_cap, c=c_cap, n=n_cap, u=u_cap: self._open_pipeline(d, c, n, u) ) pipe_l.addWidget(pipe_btn) self._table.setCellWidget(row, self._C_PIPE, pipe_w) row += 1 self._table.blockSignals(False) def _open_pipeline(self, dev_id: str, ch_id: str, ch_name: str, unit: str): dlg = _PipelineDialog(dev_id, ch_id, ch_name, unit, self._processor, self) dlg.exec() # ══════════════════════════════════════════════════════════════════════════════ # Plot tab — PlotWindow embedded without floating chrome # ══════════════════════════════════════════════════════════════════════════════ class _PlotTab(PlotWindow): def __init__(self, registry: DeviceRegistry, processor: SignalProcessor, current: Optional[LayoutConfig] = None, parent=None): QWidget.__init__(self, parent) self.registry = registry self.processor = processor self.cfg = deepcopy(current) if current else build_default_layout(registry, processor) self._blocks: List[PaneBlock] = [] self._build() self._populate() def _build(self): root = QVBoxLayout(self); root.setContentsMargins(0, 0, 0, 0); root.setSpacing(0) glob = QWidget(); glob.setObjectName("cfgGlobalBar") gl = QHBoxLayout(glob); gl.setContentsMargins(12, 7, 12, 7); gl.setSpacing(12) gl.addWidget(QLabel("Window:")) self._win = QDoubleSpinBox(); self._win.setRange(1, 3600) self._win.setSuffix(" s"); self._win.setValue(self.cfg.time_window_s) self._win.setObjectName("cfgGlobalSpin") self._win.valueChanged.connect(lambda v: setattr(self.cfg, "time_window_s", v)) gl.addWidget(self._win) self._linkx = QCheckBox("Link X"); self._linkx.setChecked(self.cfg.link_x) self._linkx.setObjectName("cfgLiveChk") self._linkx.toggled.connect(lambda v: setattr(self.cfg, "link_x", v)) gl.addWidget(self._linkx) self._legend = QCheckBox("Legend"); self._legend.setChecked(self.cfg.show_legend) self._legend.setObjectName("cfgLiveChk") self._legend.toggled.connect(lambda v: setattr(self.cfg, "show_legend", v)) gl.addWidget(self._legend) self._live = QCheckBox("Live preview"); self._live.setObjectName("cfgLiveChk") gl.addWidget(self._live) gl.addStretch() add_btn = QPushButton("+ Add Pane"); add_btn.setObjectName("addDeviceButton") add_btn.clicked.connect(self._add_pane); gl.addWidget(add_btn) root.addWidget(glob) canvas_bar = QWidget(); canvas_bar.setObjectName("layoutCanvasBar") cb_lay = QVBoxLayout(canvas_bar) cb_lay.setContentsMargins(10, 6, 10, 8); cb_lay.setSpacing(4) clbl = QLabel( "LAYOUT — edge: split (blue) | " "edge gap: insert between (amber) | center: move (green)" ) clbl.setObjectName("layoutCanvasLabel") cb_lay.addWidget(clbl) self._canvas = LayoutCanvas(self.cfg.panes, self.cfg.tree, self) self._canvas._cfg_ref = self.cfg self._canvas.arrangement_changed.connect(self._maybe_live) cb_lay.addWidget(self._canvas) root.addWidget(canvas_bar) div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) div.setObjectName("devWindowDivider"); root.addWidget(div) scroll = QScrollArea(); scroll.setWidgetResizable(True) scroll.setObjectName("deviceScroll") self._cont = QWidget() self._blay = QVBoxLayout(self._cont) self._blay.setContentsMargins(10, 10, 10, 10); self._blay.setSpacing(12) self._blay.addStretch() scroll.setWidget(self._cont) root.addWidget(scroll, 1) btm = QWidget(); btm.setObjectName("cfgBottomBar") bl = QHBoxLayout(btm); bl.setContentsMargins(12, 8, 12, 8) for lbl, fn in [("⟳ Rebuild from Channels", self._reset), ("↓ Export JSON", self._export), ("↑ Import JSON", self._import_json)]: b = QPushButton(lbl); b.setObjectName("configButton") b.clicked.connect(fn); bl.addWidget(b) bl.addStretch() ap = QPushButton("✓ Apply"); ap.setObjectName("applyButton") ap.clicked.connect(self._apply); bl.addWidget(ap) root.addWidget(btm) # ══════════════════════════════════════════════════════════════════════════════ # ConfigWindow # ══════════════════════════════════════════════════════════════════════════════ class ConfigWindow(QWidget): device_added = pyqtSignal() device_removed = pyqtSignal(str) device_reconfigured = pyqtSignal(str) channel_visibility_changed = pyqtSignal(str, str, bool) channel_name_changed = pyqtSignal(str, str, str) channel_unit_changed = pyqtSignal(str, str, str) pipeline_changed = pyqtSignal() derived_changed = pyqtSignal() layout_applied = pyqtSignal(object) closed = pyqtSignal() def __init__(self, registry: DeviceRegistry, engine: AcquisitionEngine, processor: SignalProcessor, current_layout: Optional[LayoutConfig] = None, parent=None): super().__init__(parent, Qt.WindowType.Window) self.registry = registry self.engine = engine self.processor = processor self._dev_rows: dict = {} self.setWindowTitle("Configuration") self.setMinimumSize(900, 580) self.resize(1200, 760) self._build(current_layout) # ── Build ──────────────────────────────────────────────────────────────── def _build(self, current_layout: Optional[LayoutConfig]): root = QVBoxLayout(self); root.setContentsMargins(0, 0, 0, 0); root.setSpacing(0) self.tabs = QTabWidget() self.tabs.setObjectName("signalBuilderTabs") root.addWidget(self.tabs, 1) self._build_devices_tab() self._build_channels_tab() self._build_plot_tab(current_layout) self.tabs.currentChanged.connect(self._on_tab_changed) # ── Tab 0: Devices ─────────────────────────────────────────────────────── def _build_devices_tab(self): w = QWidget() lay = QVBoxLayout(w); lay.setContentsMargins(0, 0, 0, 0); lay.setSpacing(0) bar = QWidget(); bar.setObjectName("cfgGlobalBar") bl = QHBoxLayout(bar); bl.setContentsMargins(10, 7, 10, 7); bl.setSpacing(6) lbl = QLabel("Physical Devices"); lbl.setObjectName("devWindowTitle") bl.addWidget(lbl, 1) add_btn = QPushButton("+ Add Device"); add_btn.setObjectName("addDeviceButton") add_btn.clicked.connect(self._on_add_device) bl.addWidget(add_btn) lay.addWidget(bar) div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) div.setObjectName("devWindowDivider"); lay.addWidget(div) scroll = QScrollArea(); scroll.setWidgetResizable(True) scroll.setObjectName("deviceScroll") scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self._dev_container = QWidget() self._dev_inner = QVBoxLayout(self._dev_container) self._dev_inner.setContentsMargins(10, 10, 10, 10); self._dev_inner.setSpacing(6) self._dev_inner.addStretch() scroll.setWidget(self._dev_container) lay.addWidget(scroll, 1) self.tabs.addTab(w, " Devices ") self._dev_poll = QTimer(self) self._dev_poll.setInterval(2000) self._dev_poll.timeout.connect(self._refresh_device_status) self._dev_poll.start() self.refresh_devices() def refresh_devices(self): for row in self._dev_rows.values(): self._dev_inner.removeWidget(row); row.deleteLater() self._dev_rows.clear() for dev in self.registry.all_instances(): row = DeviceRow(dev) row.configure_clicked.connect(self._on_configure_device) row.remove_clicked.connect(self._on_remove_device) self._dev_inner.insertWidget(self._dev_inner.count() - 1, row) self._dev_rows[dev.info.device_id] = row def _refresh_device_status(self): for row in self._dev_rows.values(): row.refresh() def _on_add_device(self): from ui.add_device_dialog import AddDeviceDialog 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.refresh_devices() self._signals_tab.refresh() self.device_added.emit() def _on_configure_device(self, device_id: str): from ui.config_dialog import DeviceConfigDialog dev = self.registry.get_instance(device_id) if dev: DeviceConfigDialog(dev, self).exec() self._signals_tab.refresh() self.device_reconfigured.emit(device_id) def _on_remove_device(self, device_id: str): reply = QMessageBox.question( self, "Remove Device", f"Remove '{device_id}'? This cannot be undone.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes: self.engine.remove_device(device_id) self.registry.remove_instance(device_id) self.refresh_devices() self._signals_tab.refresh() self._pipeline_tab.on_device_removed(device_id) self.device_removed.emit(device_id) # ── Tab 1: Channels ────────────────────────────────────────────────────── # Left — signal table (metadata + enable + ⚙ pipeline button per row) # Right — derived / virtual channels (PipelineTab with physical side hidden) def _build_channels_tab(self): w = QWidget() lay = QVBoxLayout(w); lay.setContentsMargins(0, 0, 0, 0); lay.setSpacing(0) splitter = QSplitter(Qt.Orientation.Horizontal) splitter.setObjectName("channelSplitter") lay.addWidget(splitter, 1) # Left: signals table sig_w = QWidget() sig_l = QVBoxLayout(sig_w); sig_l.setContentsMargins(0, 0, 0, 0); sig_l.setSpacing(0) sig_bar = QWidget(); sig_bar.setObjectName("cfgGlobalBar") sb_lay = QHBoxLayout(sig_bar); sb_lay.setContentsMargins(10, 7, 10, 7) sb_lbl = QLabel("Signals"); sb_lbl.setObjectName("devWindowTitle") sb_lay.addWidget(sb_lbl, 1) sig_l.addWidget(sig_bar) sig_div = QFrame(); sig_div.setFrameShape(QFrame.Shape.HLine) sig_div.setObjectName("devWindowDivider"); sig_l.addWidget(sig_div) self._signals_tab = _SignalsTab(self.registry, self.processor) self._signals_tab.channel_visibility_changed.connect(self._on_sig_visibility) self._signals_tab.channel_name_changed.connect(self._on_sig_name) self._signals_tab.channel_unit_changed.connect(self._on_sig_unit) sig_l.addWidget(self._signals_tab, 1) splitter.addWidget(sig_w) # Right: derived channels only self._pipeline_tab = PipelineTab(self.registry, self.processor) self._pipeline_tab.changed.connect(self.pipeline_changed) self._pipeline_tab.derived_changed.connect(self.derived_changed) inner_splitters = self._pipeline_tab.findChildren(QSplitter) if inner_splitters: inner_splitters[0].widget(0).setVisible(False) splitter.addWidget(self._pipeline_tab) splitter.setStretchFactor(0, 2) splitter.setStretchFactor(1, 1) self.tabs.addTab(w, " Channels ") def _on_sig_visibility(self, dev_id: str, ch_id: str, enabled: bool): self._pipeline_tab.on_channel_enabled_changed(dev_id, ch_id, enabled) self.channel_visibility_changed.emit(dev_id, ch_id, enabled) def _on_sig_name(self, dev_id: str, ch_id: str, name: str): self._pipeline_tab.on_channel_name_changed(dev_id, ch_id, name) self.channel_name_changed.emit(dev_id, ch_id, name) def _on_sig_unit(self, dev_id: str, ch_id: str, unit: str): self._pipeline_tab.on_channel_unit_changed(dev_id, ch_id, unit) self.channel_unit_changed.emit(dev_id, ch_id, unit) def on_device_added(self): self._signals_tab.refresh() self._pipeline_tab.on_device_added() def on_device_removed(self, dev_id: str): self._pipeline_tab.on_device_removed(dev_id) def on_device_reconfigured(self, dev_id: str): self._signals_tab.refresh() self._pipeline_tab.on_device_reconfigured(dev_id) def on_channel_enabled_changed(self, dev_id: str, ch_id: str, enabled: bool): self._pipeline_tab.on_channel_enabled_changed(dev_id, ch_id, enabled) def on_channel_name_changed(self, dev_id: str, ch_id: str, name: str): self._pipeline_tab.on_channel_name_changed(dev_id, ch_id, name) def on_channel_unit_changed(self, dev_id: str, ch_id: str, unit: str): self._pipeline_tab.on_channel_unit_changed(dev_id, ch_id, unit) def refresh_derived(self): self._pipeline_tab.refresh_derived() # ── Tab 2: Plot ────────────────────────────────────────────────────────── def _build_plot_tab(self, current_layout: Optional[LayoutConfig]): self._plot_tab = _PlotTab(self.registry, self.processor, current_layout, self) self._plot_tab.layout_applied.connect(self.layout_applied) self.tabs.addTab(self._plot_tab, " Plot ") def refresh_plot(self, cfg: Optional[LayoutConfig] = None): if cfg is not None: self._plot_tab.cfg = cfg self._plot_tab.refresh_channels() # ── Tab switch ─────────────────────────────────────────────────────────── def _on_tab_changed(self, idx: int): if idx == 1: self._signals_tab.refresh() elif idx == 2: self._plot_tab.refresh_channels() # ── Close ──────────────────────────────────────────────────────────────── def closeEvent(self, e: QCloseEvent): self._dev_poll.stop() self.closed.emit() e.accept()