diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-08-02 00:58:34 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-08-02 00:58:34 -0600 |
| commit | f6b68f1482a6b6491c9163cf7e9b1489aa24d6d0 (patch) | |
| tree | 9f97b051e73cac1c0e68798da2f00ad96ae09c42 /ui/windows | |
| parent | e1b951db7714a9496ebf7f861a6741598d4c7e67 (diff) | |
Consolidate Devices/Channels/Plot into unified ConfigWindow
Replaces three separate floating windows with single tabbed ConfigWindow:
- Tab 0: Devices (device cards, add/remove/configure)
- Tab 1: Channels (signal table with Dir/IN/OUT badge + pipeline ⚙ per row; derived channels pane)
- Tab 2: Plot (layout canvas + pane config)
Adds is_output field to ChannelConfig for output channel direction badges.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'ui/windows')
| -rw-r--r-- | ui/windows/config_window.py | 504 |
1 files changed, 504 insertions, 0 deletions
diff --git a/ui/windows/config_window.py b/ui/windows/config_window.py new file mode 100644 index 0000000..05cc1fb --- /dev/null +++ b/ui/windows/config_window.py @@ -0,0 +1,504 @@ +""" +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() |
