diff options
Diffstat (limited to 'ui/plot_config.py')
| -rw-r--r-- | ui/plot_config.py | 631 |
1 files changed, 0 insertions, 631 deletions
diff --git a/ui/plot_config.py b/ui/plot_config.py deleted file mode 100644 index c6ecaa4..0000000 --- a/ui/plot_config.py +++ /dev/null @@ -1,631 +0,0 @@ -""" -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: - if not ch.enabled: - continue - label = f"{ch.name} ({dev.info.device_id}/{ch.channel_id})" - 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() |
