summaryrefslogtreecommitdiff
path: root/ui/plot_config.py
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-04-20 16:55:57 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-04-20 16:55:57 -0600
commitd5acb04b88373d33b038bb59945fb5ab8b4f543b (patch)
tree21c37f2b54258ef20edc1b9b7226327d5f5b1a3a /ui/plot_config.py
parent425ba78ee1f760978b23a09fe8acbbc9b8b5dae4 (diff)
V8
Diffstat (limited to 'ui/plot_config.py')
-rw-r--r--ui/plot_config.py629
1 files changed, 629 insertions, 0 deletions
diff --git a/ui/plot_config.py b/ui/plot_config.py
new file mode 100644
index 0000000..058915f
--- /dev/null
+++ b/ui/plot_config.py
@@ -0,0 +1,629 @@
+"""
+ui/plot_config.py
+
+Plot Configuration Builder — floating window.
+
+Lets the user define:
+ • How many subplot rows to show
+ • Which channels go in each row (any mix of devices/channels)
+ • Per-channel: label override, color, line width, visibility
+ • Per-plot: Y-axis label, Y min/max (or auto), grid on/off
+ • Global: time window, sample decimation, background color
+
+The resulting config is stored as a list of PlotConfig dataclasses
+and emitted via the `config_applied` signal so StripChartWidget can
+rebuild itself on demand.
+"""
+
+from __future__ import annotations
+import json
+from copy import deepcopy
+from dataclasses import dataclass, field, asdict
+from typing import List, Optional
+
+from PyQt6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
+ QScrollArea, QFrame, QLineEdit, QDoubleSpinBox, QSpinBox,
+ QCheckBox, QComboBox, QColorDialog, QGroupBox, QFormLayout,
+ QSizePolicy, QSplitter, QListWidget, QListWidgetItem,
+ QAbstractItemView, QToolButton, QSlider,
+)
+from PyQt6.QtCore import Qt, pyqtSignal, QSize
+from PyQt6.QtGui import QColor, QIcon, QCloseEvent
+
+from devices.device_registry import DeviceRegistry
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Data model
+# ══════════════════════════════════════════════════════════════════════════════
+
+@dataclass
+class ChannelTrace:
+ device_id: str
+ channel_id: str
+ label: str = "" # "" → use channel name
+ color: str = "#00d4ff"
+ width: float = 1.8
+ visible: bool = True
+ style: str = "solid" # "solid" | "dash" | "dot"
+
+
+@dataclass
+class PlotConfig:
+ title: str = "Plot"
+ y_label: str = ""
+ y_auto: bool = True
+ y_min: float = -10.0
+ y_max: float = 10.0
+ grid: bool = True
+ traces: List[ChannelTrace] = field(default_factory=list)
+
+
+@dataclass
+class ChartConfig:
+ plots: List[PlotConfig] = field(default_factory=list)
+ time_window_s: float = 30.0
+ bg_color: str = "#0b0e13"
+ 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) -> "ChartConfig":
+ d = json.loads(s)
+ plots = []
+ for p in d.get("plots", []):
+ traces = [ChannelTrace(**t) for t in p.pop("traces", [])]
+ plots.append(PlotConfig(**p, traces=traces))
+ d["plots"] = plots
+ return ChartConfig(**{k: v for k, v in d.items() if k != "plots"},
+ plots=plots)
+
+
+def default_config(registry: DeviceRegistry) -> ChartConfig:
+ """Build a sensible default config from whatever devices are registered."""
+ cfg = ChartConfig()
+ for dev in registry.all_instances():
+ if not dev.info.channels:
+ continue
+ p = PlotConfig(
+ title=dev.info.name,
+ y_label=dev.info.channels[0].unit if dev.info.channels else "",
+ )
+ for ch in dev.info.channels:
+ if ch.enabled:
+ p.traces.append(ChannelTrace(
+ device_id=dev.info.device_id,
+ channel_id=ch.channel_id,
+ label=ch.name,
+ color=ch.color,
+ ))
+ if p.traces:
+ cfg.plots.append(p)
+ return cfg
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Colour swatch button
+# ══════════════════════════════════════════════════════════════════════════════
+
+class ColorButton(QPushButton):
+ color_changed = pyqtSignal(str)
+
+ def __init__(self, color: str = "#00d4ff"):
+ super().__init__()
+ self._color = color
+ self.setFixedSize(28, 22)
+ self._apply()
+ self.clicked.connect(self._pick)
+
+ def _apply(self):
+ self.setStyleSheet(
+ f"QPushButton {{ background:{self._color}; border:1px solid #2a3558;"
+ f" border-radius:3px; }} "
+ f"QPushButton:hover {{ border-color:#3b82f6; }}"
+ )
+
+ def _pick(self):
+ c = QColorDialog.getColor(QColor(self._color), self, "Pick colour")
+ if c.isValid():
+ self._color = c.name()
+ self._apply()
+ self.color_changed.emit(self._color)
+
+ @property
+ def color(self): return self._color
+
+ @color.setter
+ def color(self, v: str):
+ self._color = v
+ self._apply()
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Trace row widget (one row per ChannelTrace inside a plot)
+# ══════════════════════════════════════════════════════════════════════════════
+
+class TraceRow(QFrame):
+ removed = pyqtSignal(object) # self
+ changed = pyqtSignal()
+
+ def __init__(self, trace: ChannelTrace, registry: DeviceRegistry):
+ super().__init__()
+ self.trace = trace
+ self.registry = registry
+ self.setObjectName("traceRow")
+ self._build()
+
+ def _build(self):
+ lay = QHBoxLayout(self)
+ lay.setContentsMargins(6, 4, 6, 4)
+ lay.setSpacing(6)
+
+ # Visibility checkbox
+ self._vis = QCheckBox()
+ self._vis.setChecked(self.trace.visible)
+ self._vis.setToolTip("Show/hide trace")
+ self._vis.toggled.connect(self._on_vis)
+ lay.addWidget(self._vis)
+
+ # Color swatch
+ self._color_btn = ColorButton(self.trace.color)
+ self._color_btn.color_changed.connect(self._on_color)
+ lay.addWidget(self._color_btn)
+
+ # Source label dev_id / ch_id
+ src = QLabel(f"{self.trace.device_id} / {self.trace.channel_id}")
+ src.setObjectName("traceSource")
+ src.setMinimumWidth(120)
+ lay.addWidget(src)
+
+ # Label override
+ self._label_edit = QLineEdit(self.trace.label)
+ self._label_edit.setPlaceholderText("Label…")
+ self._label_edit.setObjectName("traceLabel")
+ self._label_edit.textChanged.connect(self._on_label)
+ lay.addWidget(self._label_edit, 1)
+
+ # Line style
+ self._style_cb = QComboBox()
+ self._style_cb.addItems(["solid", "dash", "dot"])
+ self._style_cb.setCurrentText(self.trace.style)
+ self._style_cb.setObjectName("traceStyleCb")
+ self._style_cb.setFixedWidth(64)
+ self._style_cb.currentTextChanged.connect(self._on_style)
+ lay.addWidget(self._style_cb)
+
+ # Width
+ self._width_spin = QDoubleSpinBox()
+ self._width_spin.setRange(0.5, 6.0)
+ self._width_spin.setSingleStep(0.5)
+ self._width_spin.setValue(self.trace.width)
+ self._width_spin.setFixedWidth(56)
+ self._width_spin.setObjectName("traceWidthSpin")
+ self._width_spin.valueChanged.connect(self._on_width)
+ lay.addWidget(self._width_spin)
+
+ # Remove button
+ 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()
+ def _on_label(self, t): self.trace.label = t; self.changed.emit()
+ def _on_style(self, s): self.trace.style = s; self.changed.emit()
+ def _on_width(self, w): self.trace.width = w; self.changed.emit()
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Plot block widget (one collapsible section per PlotConfig)
+# ══════════════════════════════════════════════════════════════════════════════
+
+class PlotBlock(QFrame):
+ removed = pyqtSignal(object) # self
+ changed = pyqtSignal()
+ move_up = pyqtSignal(object)
+ move_dn = pyqtSignal(object)
+
+ def __init__(self, plot_cfg: PlotConfig, registry: DeviceRegistry, index: int):
+ super().__init__()
+ self.plot_cfg = plot_cfg
+ self.registry = registry
+ self.index = index
+ self.setObjectName("plotBlock")
+ self._trace_rows: list[TraceRow] = []
+ self._build()
+
+ def _build(self):
+ outer = QVBoxLayout(self)
+ outer.setContentsMargins(0, 0, 0, 0)
+ outer.setSpacing(0)
+
+ # ── Header bar ──────────────────────────────────────────────
+ hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32)
+ hdr_lay = QHBoxLayout(hdr); hdr_lay.setContentsMargins(8, 0, 6, 0)
+
+ self._title_edit = QLineEdit(self.plot_cfg.title)
+ self._title_edit.setObjectName("plotBlockTitle")
+ self._title_edit.textChanged.connect(self._on_title)
+ hdr_lay.addWidget(self._title_edit, 1)
+
+ up_btn = QToolButton(); up_btn.setText("▲"); up_btn.setObjectName("plotMoveBtn")
+ up_btn.setFixedSize(22, 22); up_btn.clicked.connect(lambda: self.move_up.emit(self))
+ dn_btn = QToolButton(); dn_btn.setText("▼"); dn_btn.setObjectName("plotMoveBtn")
+ dn_btn.setFixedSize(22, 22); dn_btn.clicked.connect(lambda: self.move_dn.emit(self))
+ rm_btn = QToolButton(); rm_btn.setText("✕"); rm_btn.setObjectName("plotRemoveBtn")
+ rm_btn.setFixedSize(22, 22); rm_btn.clicked.connect(lambda: self.removed.emit(self))
+
+ for b in (up_btn, dn_btn, rm_btn): hdr_lay.addWidget(b)
+ outer.addWidget(hdr)
+
+ # ── Settings row ────────────────────────────────────────────
+ settings = QWidget(); settings.setObjectName("plotBlockSettings")
+ s_lay = QHBoxLayout(settings); s_lay.setContentsMargins(8, 6, 8, 6)
+
+ s_lay.addWidget(QLabel("Y Label:"))
+ self._ylabel_edit = QLineEdit(self.plot_cfg.y_label)
+ self._ylabel_edit.setPlaceholderText("e.g. Voltage (V)")
+ self._ylabel_edit.setObjectName("plotYLabelEdit")
+ self._ylabel_edit.textChanged.connect(self._on_ylabel)
+ s_lay.addWidget(self._ylabel_edit)
+
+ self._auto_chk = QCheckBox("Auto Y")
+ self._auto_chk.setChecked(self.plot_cfg.y_auto)
+ self._auto_chk.toggled.connect(self._on_auto)
+ s_lay.addWidget(self._auto_chk)
+
+ s_lay.addWidget(QLabel("Min:"))
+ self._ymin = QDoubleSpinBox()
+ self._ymin.setRange(-1e9, 1e9); self._ymin.setValue(self.plot_cfg.y_min)
+ self._ymin.setFixedWidth(72); self._ymin.setEnabled(not self.plot_cfg.y_auto)
+ self._ymin.valueChanged.connect(self._on_ymin)
+ s_lay.addWidget(self._ymin)
+
+ s_lay.addWidget(QLabel("Max:"))
+ self._ymax = QDoubleSpinBox()
+ self._ymax.setRange(-1e9, 1e9); self._ymax.setValue(self.plot_cfg.y_max)
+ self._ymax.setFixedWidth(72); self._ymax.setEnabled(not self.plot_cfg.y_auto)
+ self._ymax.valueChanged.connect(self._on_ymax)
+ s_lay.addWidget(self._ymax)
+
+ self._grid_chk = QCheckBox("Grid")
+ self._grid_chk.setChecked(self.plot_cfg.grid)
+ self._grid_chk.toggled.connect(self._on_grid)
+ s_lay.addWidget(self._grid_chk)
+
+ s_lay.addStretch()
+ outer.addWidget(settings)
+
+ # ── Traces container ─────────────────────────────────────────
+ self._traces_widget = QWidget()
+ self._traces_widget.setObjectName("plotBlockTraces")
+ self._traces_layout = QVBoxLayout(self._traces_widget)
+ self._traces_layout.setContentsMargins(4, 2, 4, 4)
+ self._traces_layout.setSpacing(2)
+
+ for tr in self.plot_cfg.traces:
+ self._add_trace_row(tr)
+
+ # ── Add trace button ─────────────────────────────────────────
+ add_row = QHBoxLayout()
+ self._ch_picker = self._build_channel_picker()
+ add_row.addWidget(self._ch_picker, 1)
+ add_tr_btn = QPushButton("+ Add Trace")
+ add_tr_btn.setObjectName("addTraceBtn")
+ add_tr_btn.clicked.connect(self._on_add_trace)
+ add_row.addWidget(add_tr_btn)
+ self._traces_layout.addLayout(add_row)
+
+ outer.addWidget(self._traces_widget)
+
+ # ── Helpers ──────────────────────────────────────────────────────────
+
+ def _build_channel_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:
+ label = f"{dev.info.device_id} / {ch.channel_id} ({ch.name})"
+ cb.addItem(label, userData=(dev.info.device_id, ch.channel_id,
+ ch.name, ch.color))
+ return cb
+
+ def _add_trace_row(self, trace: ChannelTrace):
+ row = TraceRow(trace, self.registry)
+ row.removed.connect(self._remove_trace_row)
+ row.changed.connect(self.changed)
+ self._trace_rows.append(row)
+ # Insert before the add-row (last item)
+ idx = self._traces_layout.count() - 1
+ self._traces_layout.insertWidget(idx, row)
+
+ def _remove_trace_row(self, row: TraceRow):
+ self.plot_cfg.traces.remove(row.trace)
+ self._trace_rows.remove(row)
+ self._traces_layout.removeWidget(row)
+ row.deleteLater()
+ self.changed.emit()
+
+ def _on_add_trace(self):
+ data = self._ch_picker.currentData()
+ if data is None:
+ return
+ dev_id, ch_id, ch_name, ch_color = data
+ tr = ChannelTrace(device_id=dev_id, channel_id=ch_id,
+ label=ch_name, color=ch_color)
+ self.plot_cfg.traces.append(tr)
+ self._add_trace_row(tr)
+ self.changed.emit()
+
+ # ── Config slots ─────────────────────────────────────────────────────
+
+ def _on_title(self, t): self.plot_cfg.title = t; self.changed.emit()
+ def _on_ylabel(self, t): self.plot_cfg.y_label = t; self.changed.emit()
+ def _on_auto(self, v):
+ self.plot_cfg.y_auto = v
+ self._ymin.setEnabled(not v); self._ymax.setEnabled(not v)
+ self.changed.emit()
+ def _on_ymin(self, v): self.plot_cfg.y_min = v; self.changed.emit()
+ def _on_ymax(self, v): self.plot_cfg.y_max = v; self.changed.emit()
+ def _on_grid(self, v): self.plot_cfg.grid = v; self.changed.emit()
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# PlotConfigWindow — the main floating builder
+# ══════════════════════════════════════════════════════════════════════════════
+
+class PlotConfigWindow(QWidget):
+ """
+ Floating plot configuration builder.
+
+ Emits `config_applied(ChartConfig)` when the user clicks Apply or
+ when live-preview is enabled and any setting changes.
+ """
+
+ config_applied = pyqtSignal(object) # ChartConfig
+ closed = pyqtSignal()
+
+ def __init__(self, registry: DeviceRegistry,
+ current_config: Optional[ChartConfig] = None,
+ parent=None):
+ super().__init__(
+ parent,
+ Qt.WindowType.Window | Qt.WindowType.Tool,
+ )
+ self.registry = registry
+ self.cfg = deepcopy(current_config) if current_config \
+ else default_config(registry)
+
+ self.setWindowTitle("Plot Configuration")
+ self.setMinimumSize(680, 560)
+ self.resize(760, 640)
+ self._blocks: list[PlotBlock] = []
+ self._build()
+ self._populate()
+
+ # ── Build shell ───────────────────────────────────────────────────────
+
+ def _build(self):
+ root = QVBoxLayout(self)
+ root.setContentsMargins(0, 0, 0, 0)
+ root.setSpacing(0)
+
+ # ── Top bar ──────────────────────────────────────────────────
+ topbar = QWidget(); topbar.setObjectName("cfgTopBar"); topbar.setFixedHeight(44)
+ tb_lay = QHBoxLayout(topbar); tb_lay.setContentsMargins(12, 0, 12, 0)
+
+ title = QLabel("📈 PLOT CONFIGURATION")
+ title.setObjectName("cfgTopBarTitle")
+ tb_lay.addWidget(title, 1)
+
+ self._live_chk = QCheckBox("Live preview")
+ self._live_chk.setChecked(False)
+ self._live_chk.setObjectName("cfgLiveChk")
+ tb_lay.addWidget(self._live_chk)
+
+ root.addWidget(topbar)
+
+ # ── Global settings strip ────────────────────────────────────
+ glob = QWidget(); glob.setObjectName("cfgGlobalBar")
+ g_lay = QHBoxLayout(glob); g_lay.setContentsMargins(12, 6, 12, 6)
+
+ g_lay.addWidget(QLabel("Time window:"))
+ self._win_spin = QDoubleSpinBox()
+ self._win_spin.setRange(1, 3600); self._win_spin.setSuffix(" s")
+ self._win_spin.setValue(self.cfg.time_window_s)
+ self._win_spin.setObjectName("cfgGlobalSpin")
+ self._win_spin.valueChanged.connect(self._on_global_change)
+ g_lay.addWidget(self._win_spin)
+ g_lay.addSpacing(16)
+
+ self._linkx_chk = QCheckBox("Link X axes")
+ self._linkx_chk.setChecked(self.cfg.link_x)
+ self._linkx_chk.setObjectName("cfgLiveChk")
+ self._linkx_chk.toggled.connect(self._on_global_change)
+ g_lay.addWidget(self._linkx_chk)
+
+ self._legend_chk = QCheckBox("Show legend")
+ self._legend_chk.setChecked(self.cfg.show_legend)
+ self._legend_chk.setObjectName("cfgLiveChk")
+ self._legend_chk.toggled.connect(self._on_global_change)
+ g_lay.addWidget(self._legend_chk)
+
+ g_lay.addStretch()
+
+ add_plot_btn = QPushButton("+ Add Plot Row")
+ add_plot_btn.setObjectName("addDeviceButton")
+ add_plot_btn.clicked.connect(self._add_plot)
+ g_lay.addWidget(add_plot_btn)
+
+ root.addWidget(glob)
+
+ # divider
+ div = QFrame(); div.setFrameShape(QFrame.Shape.HLine)
+ div.setObjectName("devWindowDivider"); root.addWidget(div)
+
+ # ── Scrollable plot blocks ───────────────────────────────────
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ scroll.setObjectName("deviceScroll")
+
+ self._container = QWidget()
+ self._blocks_lay = QVBoxLayout(self._container)
+ self._blocks_lay.setContentsMargins(10, 10, 10, 10)
+ self._blocks_lay.setSpacing(12)
+ self._blocks_lay.addStretch()
+
+ scroll.setWidget(self._container)
+ root.addWidget(scroll, 1)
+
+ # ── Bottom action bar ────────────────────────────────────────
+ btm = QWidget(); btm.setObjectName("cfgBottomBar")
+ b_lay = QHBoxLayout(btm); b_lay.setContentsMargins(12, 8, 12, 8)
+
+ self._reset_btn = QPushButton("⟳ Reset to Default")
+ self._reset_btn.setObjectName("configButton")
+ self._reset_btn.clicked.connect(self._reset_defaults)
+ b_lay.addWidget(self._reset_btn)
+
+ self._export_btn = QPushButton("↓ Export JSON")
+ self._export_btn.setObjectName("configButton")
+ self._export_btn.clicked.connect(self._export_json)
+ b_lay.addWidget(self._export_btn)
+
+ self._import_btn = QPushButton("↑ Import JSON")
+ self._import_btn.setObjectName("configButton")
+ self._import_btn.clicked.connect(self._import_json)
+ b_lay.addWidget(self._import_btn)
+
+ b_lay.addStretch()
+
+ self._apply_btn = QPushButton("✓ Apply")
+ self._apply_btn.setObjectName("applyButton")
+ self._apply_btn.clicked.connect(self._apply)
+ b_lay.addWidget(self._apply_btn)
+
+ root.addWidget(btm)
+
+ # ── Populate from config ───────────────────────────────────────────────
+
+ def _populate(self):
+ for blk in self._blocks:
+ self._blocks_lay.removeWidget(blk)
+ blk.deleteLater()
+ self._blocks.clear()
+
+ for i, p in enumerate(self.cfg.plots):
+ self._insert_block(p, i)
+
+ def _insert_block(self, plot_cfg: PlotConfig, index: int):
+ blk = PlotBlock(plot_cfg, self.registry, index)
+ blk.removed.connect(self._remove_block)
+ blk.changed.connect(self._on_block_changed)
+ blk.move_up.connect(self._move_block_up)
+ blk.move_dn.connect(self._move_block_dn)
+ self._blocks.append(blk)
+ self._blocks_lay.insertWidget(self._blocks_lay.count() - 1, blk)
+
+ # ── Block management ──────────────────────────────────────────────────
+
+ def _add_plot(self):
+ p = PlotConfig(title=f"Plot {len(self.cfg.plots) + 1}")
+ self.cfg.plots.append(p)
+ self._insert_block(p, len(self._blocks))
+ self._maybe_live()
+
+ def _remove_block(self, blk: PlotBlock):
+ if blk.plot_cfg in self.cfg.plots:
+ self.cfg.plots.remove(blk.plot_cfg)
+ self._blocks.remove(blk)
+ self._blocks_lay.removeWidget(blk)
+ blk.deleteLater()
+ self._maybe_live()
+
+ def _move_block_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._blocks_lay.removeWidget(blk)
+ self._blocks_lay.insertWidget(i - 1, blk)
+ self._maybe_live()
+
+ def _move_block_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._blocks_lay.removeWidget(blk)
+ self._blocks_lay.insertWidget(i + 1, blk)
+ self._maybe_live()
+
+ # ── Global settings ───────────────────────────────────────────────────
+
+ def _on_global_change(self):
+ self.cfg.time_window_s = self._win_spin.value()
+ self.cfg.link_x = self._linkx_chk.isChecked()
+ self.cfg.show_legend = self._legend_chk.isChecked()
+ self._maybe_live()
+
+ def _on_block_changed(self):
+ self._maybe_live()
+
+ def _maybe_live(self):
+ if self._live_chk.isChecked():
+ self.config_applied.emit(deepcopy(self.cfg))
+
+ # ── Actions ───────────────────────────────────────────────────────────
+
+ def _apply(self):
+ self.config_applied.emit(deepcopy(self.cfg))
+
+ def _reset_defaults(self):
+ self.cfg = default_config(self.registry)
+ self._win_spin.setValue(self.cfg.time_window_s)
+ self._populate()
+ self._maybe_live()
+
+ def _export_json(self):
+ from PyQt6.QtWidgets import QFileDialog
+ path, _ = QFileDialog.getSaveFileName(
+ self, "Export Plot Config", "plot_config.json",
+ "JSON Files (*.json)"
+ )
+ if path:
+ with open(path, "w") as f:
+ f.write(self.cfg.to_json())
+
+ def _import_json(self):
+ from PyQt6.QtWidgets import QFileDialog
+ path, _ = QFileDialog.getOpenFileName(
+ self, "Import Plot Config", "", "JSON Files (*.json)"
+ )
+ if path:
+ try:
+ with open(path) as f:
+ self.cfg = ChartConfig.from_json(f.read())
+ self._win_spin.setValue(self.cfg.time_window_s)
+ self._populate()
+ self._maybe_live()
+ except Exception as e:
+ from PyQt6.QtWidgets import QMessageBox
+ QMessageBox.critical(self, "Import failed", str(e))
+
+ def closeEvent(self, event: QCloseEvent):
+ self.closed.emit()
+ event.accept()
+
+ def update_registry(self):
+ """Call after devices are added/removed to refresh channel pickers."""
+ self._populate()