summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Kolset <ckolset@colostate.edu>2026-08-13 11:45:50 -0600
committerChristian Kolset <ckolset@colostate.edu>2026-08-13 11:45:50 -0600
commita6e812dcbf3f2fc27565e52b6c0c6f9b7e4363ee (patch)
tree8d3ca09cb823d4be10d0b99a4ed5544605909491
parent23802b2131fc0098243bab73df7be104ef9891d1 (diff)
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 <noreply@anthropic.com>
-rw-r--r--ui/windows/config_window.py4
-rw-r--r--ui/windows/plot_window.py165
2 files changed, 154 insertions, 15 deletions
diff --git a/ui/windows/config_window.py b/ui/windows/config_window.py
index a31397e..3bd3dc6 100644
--- a/ui/windows/config_window.py
+++ b/ui/windows/config_window.py
@@ -34,7 +34,7 @@ from ui.windows.devices_window import DeviceRow, ChannelsTab
from ui.windows.channels_window import PipelineTab, _slug_id, _dedupe_name
from ui.windows.plot_window import (
PlotWindow, LayoutConfig, LayoutCanvas, PaneBlock,
- build_default_layout,
+ build_default_layout, _PlotDropContainer,
)
from ui.namespace_panel import NamespacePanel
@@ -242,7 +242,7 @@ class _PlotTab(PlotWindow):
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()
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")
@@ -375,6 +375,85 @@ class TraceChannelPickerDialog(QDialog):
# ══════════════════════════════════════════════════════════════════════════════
+# 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))