From a6e812dcbf3f2fc27565e52b6c0c6f9b7e4363ee Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Thu, 13 Aug 2026 11:45:50 -0600 Subject: Configuration window UI reorg + Plot Builder polish Devices tab split into Physical Devices (left) / Signals (right); Channels tab now hosts the Namespace browser (left) alongside Channels (right, larger by default). Plot Builder panes gain drag-to-reorder (same mechanics as ControlPanel's widget list, adapted for the editable pane title via a dedicated grip icon), "+ Add Channel" moved beside Size for a more compact row, and Min/Max/Size/line-width spin boxes widened so values no longer overlap the up/down arrows. Co-Authored-By: Claude Sonnet 5 --- ui/windows/plot_window.py | 165 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 152 insertions(+), 13 deletions(-) (limited to 'ui/windows/plot_window.py') diff --git a/ui/windows/plot_window.py b/ui/windows/plot_window.py index dc1617b..93ce776 100644 --- a/ui/windows/plot_window.py +++ b/ui/windows/plot_window.py @@ -28,8 +28,8 @@ from PyQt6.QtWidgets import ( QCheckBox, QComboBox, QColorDialog, QToolButton, QFileDialog, QMessageBox, QDialog, QDialogButtonBox, ) -from PyQt6.QtCore import Qt, pyqtSignal, QPoint, QRect -from PyQt6.QtGui import QColor, QCloseEvent, QPainter, QPen +from PyQt6.QtCore import Qt, pyqtSignal, QPoint, QRect, QMimeData +from PyQt6.QtGui import QColor, QCloseEvent, QPainter, QPen, QDrag from devices.device_registry import DeviceRegistry from core.signal_processor import SignalProcessor @@ -301,7 +301,7 @@ class TraceRow(QFrame): lay.addWidget(self._sty) self._w = QDoubleSpinBox(); self._w.setObjectName("traceWidthSpin") self._w.setRange(0.5,6.0); self._w.setSingleStep(0.5) - self._w.setValue(self.tr.width); self._w.setFixedWidth(56) + self._w.setValue(self.tr.width); self._w.setMinimumWidth(68) self._w.valueChanged.connect(lambda v: setattr(self.tr,"width",v)) lay.addWidget(self._w) up = QToolButton(); up.setText("▲"); up.setObjectName("traceRemoveBtn") @@ -374,6 +374,85 @@ class TraceChannelPickerDialog(QDialog): return [(d, c, n, col) for chk, d, c, n, col in self._checks if chk.isChecked()] +# ══════════════════════════════════════════════════════════════════════════════ +# Drag-to-reorder — same mechanics as ControlPanel (ui/control_panel.py): +# a small drag handle starts a QDrag carrying the source index, the config +# list container accepts the drop and reorders. The pane title here is an +# editable QLineEdit (unlike ControlPanel's static label), so the whole +# header can't be made transparent-for-mouse — a dedicated grip is used +# instead of the whole header. +# ══════════════════════════════════════════════════════════════════════════════ + +class _PaneDragHandle(QLabel): + def __init__(self): + super().__init__("⠿") + self._owner = None + self._block = None + self.setObjectName("paneDragHandle") + self.setFixedWidth(18) + self.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.setCursor(Qt.CursorShape.OpenHandCursor) + self.setToolTip("Drag to reorder") + + def set_drag_source(self, owner: "PlotWindow", block: "PaneBlock"): + self._owner = owner + self._block = block + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton and self._owner is not None: + drag = QDrag(self) + mime = QMimeData() + mime.setText(str(self._owner._blocks.index(self._block))) + drag.setMimeData(mime) + drag.exec(Qt.DropAction.MoveAction) + super().mousePressEvent(event) + + +class _PlotDropContainer(QWidget): + """Holds the pane config blocks; drops reorder them — same drag-to-reorder + mechanics as ControlPanel's _DropContainer.""" + + def __init__(self, owner: "PlotWindow"): + super().__init__() + self._owner = owner + self._indicator_y = None + self.setAcceptDrops(True) + + def dragEnterEvent(self, event): + if event.mimeData().hasText(): + event.acceptProposedAction() + self._show_indicator(event.position().y()) + + def dragMoveEvent(self, event): + event.acceptProposedAction() + self._show_indicator(event.position().y()) + + def dragLeaveEvent(self, event): + self._indicator_y = None + self.update() + + def dropEvent(self, event): + self._indicator_y = None + self.update() + try: + src_idx = int(event.mimeData().text()) + except ValueError: + return + self._owner._reorder_panes(src_idx, self._owner._drop_index(event.position().y())) + event.acceptProposedAction() + + def _show_indicator(self, y: float): + self._indicator_y = self._owner._indicator_y_for(self._owner._drop_index(y)) + self.update() + + def paintEvent(self, event): + if self._indicator_y is not None: + painter = QPainter(self) + painter.setPen(QPen(QColor("#3b82f6"), 2)) + painter.drawLine(6, self._indicator_y, self.width() - 6, self._indicator_y) + painter.end() + + # ══════════════════════════════════════════════════════════════════════════════ # Pane block # ══════════════════════════════════════════════════════════════════════════════ @@ -395,6 +474,8 @@ class PaneBlock(QFrame): hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32) hl = QHBoxLayout(hdr); hl.setContentsMargins(8,0,6,0); hl.setSpacing(4) + self._drag_handle = _PaneDragHandle() + hl.addWidget(self._drag_handle) self._title = QLineEdit(self.spec.title); self._title.setObjectName("plotBlockTitle") self._title.textChanged.connect(lambda t: ( setattr(self.spec, "title", t), @@ -442,14 +523,14 @@ class PaneBlock(QFrame): 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.setValue(self.spec.y_min); self._ymin.setMinimumWidth(92) 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.setValue(self.spec.y_max); self._ymax.setMinimumWidth(92) self._ymax.setEnabled(not self.spec.y_auto) self._ymax.valueChanged.connect(lambda v: setattr(self.spec,"y_max",v)) sl.addWidget(self._ymax) @@ -460,20 +541,23 @@ class PaneBlock(QFrame): sl.addWidget(QLabel("Size:")) self._wt = QSpinBox(); self._wt.setRange(1,10); self._wt.setValue(self.spec.weight) - self._wt.setFixedWidth(44); self._wt.setToolTip("Relative pane size") + self._wt.setMinimumWidth(56); self._wt.setToolTip("Relative pane size") self._wt.valueChanged.connect(lambda v: setattr(self.spec,"weight",v)) - sl.addWidget(self._wt); sl.addStretch() + sl.addWidget(self._wt) + + # Right-aligned — "+ Add Channel" lives here instead of its own row + # below the trace list, saving vertical space. + sl.addStretch() + ab = QPushButton("+ Add Channel"); ab.setObjectName("addTraceBtn") + ab.clicked.connect(self._on_add) + sl.addWidget(ab) outer.addWidget(sett) tr_w = QWidget(); tr_w.setObjectName("plotBlockTraces") self._tlay = QVBoxLayout(tr_w) self._tlay.setContentsMargins(4,2,4,4); self._tlay.setSpacing(2) for tr in self.spec.traces: self._add_row(tr) - - pick_row = QHBoxLayout() - ab = QPushButton("+ Add Channel"); ab.setObjectName("addTraceBtn") - ab.clicked.connect(self._on_add); pick_row.addWidget(ab); pick_row.addStretch() - self._tlay.addLayout(pick_row) + self._tlay.addStretch() outer.addWidget(tr_w) def _add_row(self, tr: TraceSpec): @@ -991,7 +1075,7 @@ class PlotWindow(QWidget): scroll = QScrollArea(); scroll.setWidgetResizable(True) scroll.setObjectName("deviceScroll") - self._cont = QWidget() + self._cont = _PlotDropContainer(self) self._blay = QVBoxLayout(self._cont) self._blay.setContentsMargins(10,10,10,10); self._blay.setSpacing(12) self._blay.addStretch() @@ -1025,6 +1109,7 @@ class PlotWindow(QWidget): blk.removed.connect(self._rm) blk.changed.connect(self._maybe_live) blk.title_changed.connect(self._on_tile_title) + blk._drag_handle.set_drag_source(self, blk) self._blocks.append(blk) self._blay.insertWidget(self._blay.count()-1, blk) @@ -1063,6 +1148,60 @@ class PlotWindow(QWidget): self._canvas.sync_panes(self.cfg.panes, self.cfg.tree) self._maybe_live() + def _drop_index(self, y: float) -> int: + """Insertion index for a drop at container-relative y.""" + n = self._blay.count() - 1 # exclude trailing stretch + for i in range(n): + w = self._blay.itemAt(i).widget() + if y < w.y() + w.height() / 2: + return i + return n + + def _indicator_y_for(self, idx: int) -> int: + """Container-relative y for the drop-line at insertion index idx.""" + n = self._blay.count() - 1 # exclude trailing stretch + spacing = self._blay.spacing() + if n == 0: + return self._blay.contentsMargins().top() + if idx <= 0: + w = self._blay.itemAt(0).widget() + return w.y() - spacing // 2 + if idx >= n: + w = self._blay.itemAt(n - 1).widget() + return w.y() + w.height() + spacing // 2 + prev = self._blay.itemAt(idx - 1).widget() + nxt = self._blay.itemAt(idx).widget() + return (prev.y() + prev.height() + nxt.y()) // 2 + + def _reorder_panes(self, src_idx: int, dst_idx: int): + """Move the pane at src_idx to dst_idx (drop-target index, pre-removal) + — same semantics as ControlPanel._reorder(). Also walks the BSP tree's + leaf pane-index labels through the same chain of adjacent swaps so + each grid tile keeps showing the same pane content after the move.""" + if dst_idx > src_idx: + dst_idx -= 1 + if dst_idx == src_idx: + return + + spec = self.cfg.panes.pop(src_idx) + self.cfg.panes.insert(dst_idx, spec) + + if self.cfg.tree: + step = 1 if dst_idx > src_idx else -1 + idx = src_idx + while idx != dst_idx: + nxt_idx = idx + step + self.cfg.tree = _tree_swap(self.cfg.tree, idx, nxt_idx) + idx = nxt_idx + + blk = self._blocks.pop(src_idx) + self._blocks.insert(dst_idx, blk) + self._blay.removeWidget(blk) + self._blay.insertWidget(dst_idx, blk) + + self._canvas.sync_panes(self.cfg.panes, self.cfg.tree) + self._maybe_live() + def _maybe_live(self): if self._live.isChecked(): self.layout_applied.emit(deepcopy(self.cfg)) -- cgit v1.2.3