""" ui/plot_builder.py Plot Builder — floating window for operators to: • Create named subplot rows • Drag channels (physical + derived) into any row • Name each axis, set Y range, grid, color • Reorder rows with ▲▼ arrows • Toggle per-trace visibility • Export / Import config as JSON Emits config_applied(ChartConfig) to rebuild the strip chart. """ from __future__ import annotations import json from copy import deepcopy from dataclasses import dataclass, field, asdict from typing import List, Optional, Tuple from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QScrollArea, QFrame, QLineEdit, QDoubleSpinBox, QCheckBox, QComboBox, QColorDialog, QToolButton, QSizePolicy, QFileDialog, QMessageBox, QSpinBox, ) from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtGui import QColor, QFont, QCloseEvent from devices.device_registry import DeviceRegistry from core.signal_processor import SignalProcessor # ══════════════════════════════════════════════════════════════════════════════ # Data model (same as before, kept self-contained for clarity) # ══════════════════════════════════════════════════════════════════════════════ @dataclass class TraceSpec: device_id: str channel_id: str label: str = "" color: str = "#00d4ff" width: float = 1.8 visible: bool = True style: str = "solid" # solid | dash | dot @dataclass class PlotSpec: title: str = "Plot" y_label: str = "" y_auto: bool = True y_min: float = -10.0 y_max: float = 10.0 grid: bool = True height: int = 1 # relative weight in splitter traces: List[TraceSpec] = field(default_factory=list) @dataclass class LayoutConfig: plots: List[PlotSpec] = field(default_factory=list) time_window_s: float = 30.0 link_x: bool = True show_legend: bool = True def to_json(self) -> str: return json.dumps(asdict(self), indent=2) @staticmethod def from_json(s: str) -> "LayoutConfig": d = json.loads(s) plots = [] for p in d.get("plots", []): traces = [TraceSpec(**t) for t in p.pop("traces", [])] plots.append(PlotSpec(**p, traces=traces)) d.pop("plots") return LayoutConfig(**d, plots=plots) def build_default_layout(registry: DeviceRegistry, processor: SignalProcessor) -> LayoutConfig: cfg = LayoutConfig() for dev in registry.all_instances(): enabled = [ch for ch in dev.info.channels if ch.enabled] if not enabled: continue p = PlotSpec(title=dev.info.name, y_label=enabled[0].unit if enabled else "") for ch in enabled: p.traces.append(TraceSpec( device_id=dev.info.device_id, channel_id=ch.channel_id, label=ch.name, color=ch.color, )) if p.traces: cfg.plots.append(p) # Add derived channels for dc in processor.get_derived(): p = PlotSpec(title=dc.name, y_label=dc.unit) p.traces.append(TraceSpec( device_id="derived", channel_id=dc.channel_id, label=dc.name, color=dc.color, )) cfg.plots.append(p) return cfg # ══════════════════════════════════════════════════════════════════════════════ # Color swatch button # ══════════════════════════════════════════════════════════════════════════════ class ColorSwatch(QPushButton): color_changed = pyqtSignal(str) def __init__(self, color: str = "#00d4ff"): super().__init__() self._color = color self.setFixedSize(26, 20) self._paint() self.clicked.connect(self._pick) def _paint(self): self.setStyleSheet( f"QPushButton{{background:{self._color};border:1px solid #2a3558;" f"border-radius:3px;}}QPushButton:hover{{border-color:#3b82f6;}}") def _pick(self): c = QColorDialog.getColor(QColor(self._color), self) if c.isValid(): self._color = c.name(); self._paint() self.color_changed.emit(self._color) @property def color(self): return self._color @color.setter def color(self, v): self._color = v; self._paint() # ══════════════════════════════════════════════════════════════════════════════ # Trace row inside a plot block # ══════════════════════════════════════════════════════════════════════════════ class TraceRow(QFrame): removed = pyqtSignal(object) changed = pyqtSignal() def __init__(self, trace: TraceSpec): super().__init__() self.trace = trace self.setObjectName("traceRow") self._build() def _build(self): lay = QHBoxLayout(self) lay.setContentsMargins(6, 3, 6, 3); lay.setSpacing(6) self._vis = QCheckBox(); self._vis.setChecked(self.trace.visible) self._vis.setToolTip("Visible"); self._vis.toggled.connect(self._on_vis) lay.addWidget(self._vis) self._color = ColorSwatch(self.trace.color) self._color.color_changed.connect(self._on_color) lay.addWidget(self._color) src = QLabel(f"{self.trace.device_id} / {self.trace.channel_id}") src.setObjectName("traceSource"); src.setMinimumWidth(130) lay.addWidget(src) self._lbl = QLineEdit(self.trace.label) self._lbl.setObjectName("traceLabel"); self._lbl.setPlaceholderText("Label…") self._lbl.textChanged.connect(lambda t: setattr(self.trace, "label", t)) lay.addWidget(self._lbl, 1) self._style = QComboBox(); self._style.setObjectName("traceStyleCb") self._style.addItems(["solid", "dash", "dot"]) self._style.setCurrentText(self.trace.style); self._style.setFixedWidth(64) self._style.currentTextChanged.connect(lambda s: setattr(self.trace, "style", s)) lay.addWidget(self._style) self._w = QDoubleSpinBox(); self._w.setObjectName("traceWidthSpin") self._w.setRange(0.5, 6.0); self._w.setSingleStep(0.5) self._w.setValue(self.trace.width); self._w.setFixedWidth(56) self._w.valueChanged.connect(lambda v: setattr(self.trace, "width", v)) lay.addWidget(self._w) rm = QToolButton(); rm.setText("✕"); rm.setObjectName("traceRemoveBtn") rm.setFixedSize(22, 22); rm.clicked.connect(lambda: self.removed.emit(self)) lay.addWidget(rm) def _on_vis(self, v): self.trace.visible = v; self.changed.emit() def _on_color(self, c): self.trace.color = c; self.changed.emit() # ══════════════════════════════════════════════════════════════════════════════ # Plot block — one collapsible row per PlotSpec # ══════════════════════════════════════════════════════════════════════════════ class PlotBlock(QFrame): removed = pyqtSignal(object) changed = pyqtSignal() move_up = pyqtSignal(object) move_dn = pyqtSignal(object) def __init__(self, spec: PlotSpec, registry: DeviceRegistry, processor: SignalProcessor): super().__init__() self.spec = spec self.registry = registry self.processor = processor self._rows: List[TraceRow] = [] self.setObjectName("plotBlock") self._build() def _build(self): outer = QVBoxLayout(self); outer.setContentsMargins(0,0,0,0); outer.setSpacing(0) # ── Header ──────────────────────────────────────────────────── hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32) hl = QHBoxLayout(hdr); hl.setContentsMargins(8,0,6,0); hl.setSpacing(4) self._title = QLineEdit(self.spec.title) self._title.setObjectName("plotBlockTitle") self._title.textChanged.connect(lambda t: (setattr(self.spec, "title", t), self.changed.emit())) hl.addWidget(self._title, 1) for txt, sig in [("▲", self.move_up), ("▼", self.move_dn)]: b = QToolButton(); b.setText(txt); b.setObjectName("plotMoveBtn") b.setFixedSize(22,22); b.clicked.connect(lambda _, s=sig: s.emit(self)) hl.addWidget(b) rm = QToolButton(); rm.setText("✕"); rm.setObjectName("plotRemoveBtn") rm.setFixedSize(22,22); rm.clicked.connect(lambda: self.removed.emit(self)) hl.addWidget(rm) outer.addWidget(hdr) # ── Axis / range settings ───────────────────────────────────── sett = QWidget(); sett.setObjectName("plotBlockSettings") sl = QHBoxLayout(sett); sl.setContentsMargins(8,6,8,6); sl.setSpacing(8) sl.addWidget(QLabel("Y Axis:")) self._ylabel = QLineEdit(self.spec.y_label) self._ylabel.setObjectName("plotYLabelEdit"); self._ylabel.setMaximumWidth(130) self._ylabel.setPlaceholderText("e.g. Voltage (V)") self._ylabel.textChanged.connect(lambda t: setattr(self.spec, "y_label", t)) sl.addWidget(self._ylabel) self._auto = QCheckBox("Auto Y"); self._auto.setChecked(self.spec.y_auto) self._auto.toggled.connect(self._on_auto) sl.addWidget(self._auto) sl.addWidget(QLabel("Min:")) self._ymin = QDoubleSpinBox(); self._ymin.setRange(-1e9,1e9) self._ymin.setValue(self.spec.y_min); self._ymin.setFixedWidth(72) self._ymin.setEnabled(not self.spec.y_auto) self._ymin.valueChanged.connect(lambda v: setattr(self.spec, "y_min", v)) sl.addWidget(self._ymin) sl.addWidget(QLabel("Max:")) self._ymax = QDoubleSpinBox(); self._ymax.setRange(-1e9,1e9) self._ymax.setValue(self.spec.y_max); self._ymax.setFixedWidth(72) self._ymax.setEnabled(not self.spec.y_auto) self._ymax.valueChanged.connect(lambda v: setattr(self.spec, "y_max", v)) sl.addWidget(self._ymax) self._grid = QCheckBox("Grid"); self._grid.setChecked(self.spec.grid) self._grid.toggled.connect(lambda v: setattr(self.spec, "grid", v)) sl.addWidget(self._grid) sl.addWidget(QLabel("H:")) self._height_spin = QSpinBox(); self._height_spin.setRange(1,10) self._height_spin.setValue(self.spec.height); self._height_spin.setFixedWidth(44) self._height_spin.setToolTip("Relative row height") self._height_spin.valueChanged.connect(lambda v: setattr(self.spec, "height", v)) sl.addWidget(self._height_spin) sl.addStretch() outer.addWidget(sett) # ── Trace rows ──────────────────────────────────────────────── traces_w = QWidget(); traces_w.setObjectName("plotBlockTraces") self._tlay = QVBoxLayout(traces_w) self._tlay.setContentsMargins(4,2,4,4); self._tlay.setSpacing(2) for tr in self.spec.traces: self._add_trace_row(tr) # Channel picker + add button pick_row = QHBoxLayout() self._picker = self._make_picker() pick_row.addWidget(self._picker, 1) add_btn = QPushButton("+ Add Channel") add_btn.setObjectName("addTraceBtn") add_btn.clicked.connect(self._on_add_trace) pick_row.addWidget(add_btn) self._tlay.addLayout(pick_row) outer.addWidget(traces_w) def _make_picker(self) -> QComboBox: cb = QComboBox(); cb.setObjectName("channelPickerCb") cb.setPlaceholderText("Select channel…") for dev in self.registry.all_instances(): for ch in dev.info.channels: cb.addItem(f"{dev.info.device_id} / {ch.channel_id} ({ch.name})", userData=(dev.info.device_id, ch.channel_id, ch.name, ch.color)) for dc in self.processor.get_derived(): cb.addItem(f"[derived] {dc.channel_id} ({dc.name})", userData=("derived", dc.channel_id, dc.name, dc.color)) return cb def _add_trace_row(self, trace: TraceSpec): row = TraceRow(trace) row.removed.connect(self._remove_trace) row.changed.connect(self.changed) self._rows.append(row) self._tlay.insertWidget(self._tlay.count() - 1, row) def _remove_trace(self, row: TraceRow): if row.trace in self.spec.traces: self.spec.traces.remove(row.trace) self._rows.remove(row) self._tlay.removeWidget(row); row.deleteLater() self.changed.emit() def _on_add_trace(self): data = self._picker.currentData() if not data: return dev_id, ch_id, ch_name, ch_color = data tr = TraceSpec(device_id=dev_id, channel_id=ch_id, label=ch_name, color=ch_color) self.spec.traces.append(tr) self._add_trace_row(tr) self.changed.emit() def _on_auto(self, v: bool): self.spec.y_auto = v self._ymin.setEnabled(not v); self._ymax.setEnabled(not v) # ══════════════════════════════════════════════════════════════════════════════ # PlotBuilderWindow # ══════════════════════════════════════════════════════════════════════════════ class PlotBuilderWindow(QWidget): """ Floating Plot Builder. Emits config_applied(LayoutConfig) on Apply. """ config_applied = pyqtSignal(object) # LayoutConfig closed = pyqtSignal() def __init__(self, registry: DeviceRegistry, processor: SignalProcessor, current: Optional[LayoutConfig] = None, parent=None): super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool) self.registry = registry self.processor = processor self.cfg = deepcopy(current) if current \ else build_default_layout(registry, processor) self.setWindowTitle("Plot Builder") self.setMinimumSize(700, 560) self.resize(800, 680) self._blocks: List[PlotBlock] = [] self._build() self._populate() def _build(self): root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0); root.setSpacing(0) # ── Top bar ────────────────────────────────────────────────── top = QWidget(); top.setObjectName("cfgTopBar"); top.setFixedHeight(44) tl = QHBoxLayout(top); tl.setContentsMargins(12,0,12,0) title = QLabel("📐 PLOT BUILDER"); title.setObjectName("cfgTopBarTitle") tl.addWidget(title, 1) self._live = QCheckBox("Live preview"); self._live.setObjectName("cfgLiveChk") tl.addWidget(self._live) root.addWidget(top) # ── Global settings ────────────────────────────────────────── glob = QWidget(); glob.setObjectName("cfgGlobalBar") gl = QHBoxLayout(glob); gl.setContentsMargins(12,6,12,6); gl.setSpacing(12) gl.addWidget(QLabel("Time 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(self._global_changed) gl.addWidget(self._win) self._linkx = QCheckBox("Link X"); self._linkx.setChecked(self.cfg.link_x) self._linkx.setObjectName("cfgLiveChk"); self._linkx.toggled.connect(self._global_changed) gl.addWidget(self._linkx) self._legend = QCheckBox("Legend"); self._legend.setChecked(self.cfg.show_legend) self._legend.setObjectName("cfgLiveChk"); self._legend.toggled.connect(self._global_changed) gl.addWidget(self._legend) gl.addStretch() add_row_btn = QPushButton("+ Add Plot Row"); add_row_btn.setObjectName("addDeviceButton") add_row_btn.clicked.connect(self._add_plot) gl.addWidget(add_row_btn) root.addWidget(glob) div = QFrame(); div.setFrameShape(QFrame.Shape.HLine); div.setObjectName("devWindowDivider") root.addWidget(div) # ── Scrollable plot blocks ─────────────────────────────────── scroll = QScrollArea(); scroll.setWidgetResizable(True); scroll.setObjectName("deviceScroll") self._container = QWidget() self._blay = QVBoxLayout(self._container) self._blay.setContentsMargins(10,10,10,10); self._blay.setSpacing(12) self._blay.addStretch() scroll.setWidget(self._container) root.addWidget(scroll, 1) # ── Bottom bar ─────────────────────────────────────────────── btm = QWidget(); btm.setObjectName("cfgBottomBar") bl = QHBoxLayout(btm); bl.setContentsMargins(12,8,12,8) for lbl, fn in [("⟳ Rebuild from Devices", self._reset), ("↓ Export JSON", self._export), ("↑ Import JSON", self._import)]: 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) def _populate(self): for b in self._blocks: self._blay.removeWidget(b); b.deleteLater() self._blocks.clear() for spec in self.cfg.plots: self._insert_block(spec) def _insert_block(self, spec: PlotSpec): blk = PlotBlock(spec, self.registry, self.processor) blk.removed.connect(self._remove_block) blk.changed.connect(self._maybe_live) blk.move_up.connect(self._move_up) blk.move_dn.connect(self._move_dn) self._blocks.append(blk) self._blay.insertWidget(self._blay.count() - 1, blk) def _add_plot(self): spec = PlotSpec(title=f"Plot {len(self.cfg.plots)+1}") self.cfg.plots.append(spec) self._insert_block(spec) self._maybe_live() def _remove_block(self, blk: PlotBlock): if blk.spec in self.cfg.plots: self.cfg.plots.remove(blk.spec) self._blocks.remove(blk) self._blay.removeWidget(blk); blk.deleteLater() self._maybe_live() def _move_up(self, blk: PlotBlock): i = self._blocks.index(blk) if i == 0: return self.cfg.plots.insert(i-1, self.cfg.plots.pop(i)) self._blocks.insert(i-1, self._blocks.pop(i)) self._blay.removeWidget(blk); self._blay.insertWidget(i-1, blk) self._maybe_live() def _move_dn(self, blk: PlotBlock): i = self._blocks.index(blk) if i >= len(self._blocks)-1: return self.cfg.plots.insert(i+1, self.cfg.plots.pop(i)) self._blocks.insert(i+1, self._blocks.pop(i)) self._blay.removeWidget(blk); self._blay.insertWidget(i+1, blk) self._maybe_live() def _global_changed(self): self.cfg.time_window_s = self._win.value() self.cfg.link_x = self._linkx.isChecked() self.cfg.show_legend = self._legend.isChecked() self._maybe_live() def _maybe_live(self): if self._live.isChecked(): self.config_applied.emit(deepcopy(self.cfg)) def _apply(self): self.config_applied.emit(deepcopy(self.cfg)) def _reset(self): self.cfg = build_default_layout(self.registry, self.processor) self._win.setValue(self.cfg.time_window_s) self._populate(); self._maybe_live() def _export(self): path, _ = QFileDialog.getSaveFileName(self, "Export Layout", "layout.json", "JSON (*.json)") if path: open(path, "w").write(self.cfg.to_json()) def _import(self): path, _ = QFileDialog.getOpenFileName(self, "Import Layout", "", "JSON (*.json)") if path: try: self.cfg = LayoutConfig.from_json(open(path).read()) self._win.setValue(self.cfg.time_window_s) self._populate(); self._maybe_live() except Exception as e: QMessageBox.critical(self, "Import failed", str(e)) def refresh_channels(self): """Call after devices/derived channels change.""" self._populate() def closeEvent(self, event: QCloseEvent): self.closed.emit(); event.accept()