From 9f0bdec9f1f410e121380e7b956c069ae0eed7db Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Mon, 25 May 2026 21:51:39 +0200 Subject: [PATCH] Redesign plot tab: per-plot add/remove traces, persist settings, readable axes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace static combo grid with a control bar above each plot: add-trace dropdown + "+" button; active traces shown as colored chips with "×" to remove (max 4 per plot) - Save trace selection to ~/.config/we_monitor/plot_settings.json on every change; restore automatically once matching signals arrive from the server - Remove tiny tick-font overrides so axis labels are readable at default size - Show received record count in the status bar Co-Authored-By: Claude Sonnet 4.6 --- gui_client.py | 220 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 154 insertions(+), 66 deletions(-) diff --git a/gui_client.py b/gui_client.py index 668e5cd..94fbe3f 100644 --- a/gui_client.py +++ b/gui_client.py @@ -17,6 +17,7 @@ import sys import threading from collections import deque from datetime import datetime +from pathlib import Path from PyQt5.QtCore import QObject, Qt, pyqtSignal from PyQt5.QtGui import QColor, QFont @@ -32,7 +33,8 @@ import pyqtgraph as pg pg.setConfigOption("background", "w") pg.setConfigOption("foreground", "k") -MAX_PLOT_POINTS = 200 +MAX_PLOT_POINTS = 200 +_SETTINGS_PATH = Path.home() / ".config" / "we_monitor" / "plot_settings.json" # ── TCP reader ──────────────────────────────────────────────────────────────── @@ -269,41 +271,17 @@ class DashboardTab(QWidget): # ── Plot tab ────────────────────────────────────────────────────────────────── class PlotTab(QWidget): - NUM_PLOTS = 4 - TRACES = 4 - COLORS = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728"] + NUM_PLOTS = 4 + MAX_TRACES = 4 + COLORS = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728"] def __init__(self): super().__init__() - layout = QVBoxLayout(self) - layout.setContentsMargins(4, 4, 4, 4) - layout.setSpacing(4) + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(4, 4, 4, 4) + main_layout.setSpacing(4) - # ── trace selectors ─────────────────────────────────────────────────── - sel = QWidget() - sel.setMaximumHeight(120) - sel_grid = QGridLayout(sel) - sel_grid.setContentsMargins(0, 0, 0, 0) - sel_grid.setSpacing(3) - - self._combos: list[list[QComboBox]] = [] - for i in range(self.NUM_PLOTS): - lbl = QLabel(f"Plot {i + 1}") - lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - sel_grid.addWidget(lbl, i, 0) - row: list[QComboBox] = [] - for j in range(self.TRACES): - cb = QComboBox() - cb.setMinimumWidth(160) - cb.addItem("—") - cb.currentIndexChanged.connect(self._on_selection_changed) - sel_grid.addWidget(cb, i, j + 1) - row.append(cb) - self._combos.append(row) - - layout.addWidget(sel) - - # ── toolbar row ─────────────────────────────────────────────────────── + # ── toolbar ─────────────────────────────────────────────────────────── tb = QWidget() tb_layout = QHBoxLayout(tb) tb_layout.setContentsMargins(0, 0, 0, 0) @@ -316,27 +294,68 @@ class PlotTab(QWidget): self._cursor_label.setStyleSheet("font-size: 11px; color: #333;") tb_layout.addWidget(self._cursor_label) tb_layout.addStretch() - layout.addWidget(tb) + main_layout.addWidget(tb) - # ── 2×2 pyqtgraph plot grid ─────────────────────────────────────────── + # ── data ────────────────────────────────────────────────────────────── + self._traces: list[list[str]] = [[] for _ in range(self.NUM_PLOTS)] + self._saved_traces: list[list[str]] = self._load_settings() + self._series: dict[str, deque] = {} + self._units: dict[str, str] = {} + + # ── 2×2 plot grid (each cell: control bar + plot) ───────────────────── plot_container = QWidget() plot_grid = QGridLayout(plot_container) plot_grid.setContentsMargins(0, 0, 0, 0) plot_grid.setSpacing(4) - self._plot_widgets: list[pg.PlotWidget] = [] + self._plot_widgets: list[pg.PlotWidget] = [] self._vlines: list[pg.InfiniteLine] = [] - self._proxies: list = [] # keep SignalProxy refs alive + self._proxies: list = [] + self._add_combos: list[QComboBox] = [] + self._chip_bars: list[QHBoxLayout] = [] for i in range(self.NUM_PLOTS): + cell = QWidget() + cell_vbox = QVBoxLayout(cell) + cell_vbox.setContentsMargins(0, 0, 0, 0) + cell_vbox.setSpacing(2) + + # control bar + ctrl = QWidget() + ctrl.setMaximumHeight(30) + ctrl_row = QHBoxLayout(ctrl) + ctrl_row.setContentsMargins(0, 0, 0, 0) + ctrl_row.setSpacing(4) + + add_combo = QComboBox() + add_combo.setMinimumWidth(180) + add_combo.addItem("— add trace —") + add_combo.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + self._add_combos.append(add_combo) + ctrl_row.addWidget(add_combo) + + add_btn = QPushButton("+") + add_btn.setFixedSize(26, 22) + add_btn.clicked.connect(lambda checked, idx=i: self._add_trace(idx)) + ctrl_row.addWidget(add_btn) + + ctrl_row.addSpacing(8) + chip_bar = QHBoxLayout() + chip_bar.setSpacing(4) + chip_bar.setContentsMargins(0, 0, 0, 0) + self._chip_bars.append(chip_bar) + ctrl_row.addLayout(chip_bar) + ctrl_row.addStretch() + + cell_vbox.addWidget(ctrl) + + # plot pw = pg.PlotWidget( background="white", axisItems={"bottom": pg.DateAxisItem(orientation="bottom")}, ) pw.showGrid(x=True, y=True, alpha=0.3) - pw.getAxis("bottom").setStyle(tickFont=QFont("", 7)) - pw.getAxis("left").setStyle(tickFont=QFont("", 7)) - pw.addLegend(offset=(0, 0), labelTextSize="7pt") + pw.addLegend(offset=(0, 0), labelTextSize="8pt") pw.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) vl = pg.InfiniteLine( @@ -354,14 +373,55 @@ class PlotTab(QWidget): ) self._proxies.append(proxy) - plot_grid.addWidget(pw, i // 2, i % 2) + cell_vbox.addWidget(pw) self._plot_widgets.append(pw) + plot_grid.addWidget(cell, i // 2, i % 2) - layout.addWidget(plot_container) + main_layout.addWidget(plot_container) - # ── data ────────────────────────────────────────────────────────────── - self._series: dict[str, deque] = {} - self._units: dict[str, str] = {} + # ── trace management ────────────────────────────────────────────────────── + + def _add_trace(self, plot_idx: int): + key = self._add_combos[plot_idx].currentText() + traces = self._traces[plot_idx] + if key.startswith("—") or key not in self._series: + return + if key in traces or len(traces) >= self.MAX_TRACES: + return + traces.append(key) + self._add_chip(plot_idx, key, len(traces) - 1) + self._save_settings() + self._redraw() + + def _add_chip(self, plot_idx: int, key: str, color_idx: int): + short = key.split(".")[-1] + color = self.COLORS[color_idx] + btn = QPushButton(f"{short} ×") + btn.setFixedHeight(22) + btn.setStyleSheet( + f"QPushButton {{ background:{color}; color:white; border-radius:3px;" + f" padding:1px 6px; font-size:11px; }}" + f"QPushButton:hover {{ background:#888; }}" + ) + btn.clicked.connect(lambda: self._remove_trace(plot_idx, key)) + self._chip_bars[plot_idx].addWidget(btn) + + def _remove_trace(self, plot_idx: int, key: str): + if key not in self._traces[plot_idx]: + return + self._traces[plot_idx].remove(key) + self._rebuild_chips(plot_idx) + self._save_settings() + self._redraw() + + def _rebuild_chips(self, plot_idx: int): + bar = self._chip_bars[plot_idx] + while bar.count(): + item = bar.takeAt(0) + if item.widget(): + item.widget().deleteLater() + for j, key in enumerate(self._traces[plot_idx]): + self._add_chip(plot_idx, key, j) # ── public update called on every new snapshot ──────────────────────────── @@ -392,24 +452,51 @@ class PlotTab(QWidget): self._redraw() - # ── combo management ────────────────────────────────────────────────────── + # ── combo refresh + saved-trace restore ────────────────────────────────── def _refresh_combos(self): signals = sorted(self._series.keys()) - for row in self._combos: - for cb in row: - current = cb.currentText() - cb.blockSignals(True) - cb.clear() - cb.addItem("—") - for s in signals: - cb.addItem(s) - idx = cb.findText(current) - cb.setCurrentIndex(idx if idx >= 0 else 0) - cb.blockSignals(False) + for combo in self._add_combos: + current = combo.currentText() + combo.blockSignals(True) + combo.clear() + combo.addItem("— add trace —") + for s in signals: + combo.addItem(s) + idx = combo.findText(current) + combo.setCurrentIndex(idx if idx >= 0 else 0) + combo.blockSignals(False) - def _on_selection_changed(self): - self._redraw() + for i, saved in enumerate(self._saved_traces): + for key in list(saved): + if key not in self._series: + continue + traces = self._traces[i] + if key not in traces and len(traces) < self.MAX_TRACES: + traces.append(key) + self._add_chip(i, key, len(traces) - 1) + saved.remove(key) + + # ── settings persistence ────────────────────────────────────────────────── + + def _save_settings(self): + try: + _SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) + _SETTINGS_PATH.write_text( + json.dumps({"traces": [list(t) for t in self._traces]}, indent=2) + ) + except OSError: + pass + + def _load_settings(self) -> list[list[str]]: + try: + data = json.loads(_SETTINGS_PATH.read_text()) + saved = data.get("traces", []) + while len(saved) < self.NUM_PLOTS: + saved.append([]) + return [list(t) for t in saved[: self.NUM_PLOTS]] + except (OSError, json.JSONDecodeError, KeyError): + return [[] for _ in range(self.NUM_PLOTS)] # ── drawing ─────────────────────────────────────────────────────────────── @@ -421,9 +508,8 @@ class PlotTab(QWidget): if pi.legend: pi.legend.clear() - for j, cb in enumerate(self._combos[i]): - key = cb.currentText() - if key == "—" or key not in self._series: + for j, key in enumerate(self._traces[i]): + if key not in self._series: continue series = self._series[key] if not series: @@ -459,9 +545,8 @@ class PlotTab(QWidget): vl.setVisible(True) parts = [] - for j, cb in enumerate(self._combos[plot_idx]): - key = cb.currentText() - if key == "—" or key not in self._series: + for j, key in enumerate(self._traces[plot_idx]): + if key not in self._series: continue series = list(self._series[key]) if not series: @@ -506,6 +591,7 @@ class MainWindow(QMainWindow): self._connector.connect_requested.connect(self._connect) self._connector.disconnect_requested.connect(self._disconnect) + self._record_count = 0 def _connect(self, host: str, port: int): err = self._reader.connect_to(host, port) @@ -513,8 +599,9 @@ class MainWindow(QMainWindow): self._connector.set_disconnected(err) self._statusbar.showMessage(f"Connection failed: {err}") else: + self._record_count = 0 self._connector.set_connected(host, port) - self._statusbar.showMessage(f"Connected to {host}:{port}") + self._statusbar.showMessage(f"Connected to {host}:{port} | Records: 0") def _disconnect(self): self._reader.disconnect() @@ -524,8 +611,9 @@ class MainWindow(QMainWindow): def _on_message(self, data: dict): self._dashboard.update(data) self._plot.update(data) + self._record_count += 1 self._statusbar.showMessage( - f"Last snapshot: {data.get('ts', '?')}" + f"Last snapshot: {data.get('ts', '?')} | Records: {self._record_count}" ) def _on_connection_lost(self, reason: str):