summaryrefslogtreecommitdiff
path: root/ui/control_panel.py
diff options
context:
space:
mode:
Diffstat (limited to 'ui/control_panel.py')
-rw-r--r--ui/control_panel.py200
1 files changed, 141 insertions, 59 deletions
diff --git a/ui/control_panel.py b/ui/control_panel.py
index aef0f89..3499a44 100644
--- a/ui/control_panel.py
+++ b/ui/control_panel.py
@@ -29,8 +29,8 @@ from PyQt6.QtWidgets import (
QScrollArea, QFrame, QSizePolicy, QGridLayout,
QSpinBox, QGroupBox,
)
-from PyQt6.QtCore import Qt, pyqtSignal, QTimer
-from PyQt6.QtGui import QFont
+from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QMimeData
+from PyQt6.QtGui import QFont, QDrag, QPainter, QPen, QColor
from devices.device_registry import DeviceRegistry
from devices.base_device import DeviceStatus
@@ -40,6 +40,30 @@ from devices.base_device import DeviceStatus
# Base widget
# ══════════════════════════════════════════════════════════════════════════════
+class _ControlHeader(QWidget):
+ """Header bar — doubles as the drag handle for reordering.
+ Clicks land here except where the Edit/Remove buttons (added on top) intercept them."""
+
+ def __init__(self):
+ super().__init__()
+ self._panel = None
+ self._widget = None
+ self.setCursor(Qt.CursorShape.OpenHandCursor)
+
+ def set_drag_source(self, panel: "ControlPanel", widget: "ControlWidget"):
+ self._panel = panel
+ self._widget = widget
+
+ def mousePressEvent(self, event):
+ if event.button() == Qt.MouseButton.LeftButton and self._panel is not None:
+ drag = QDrag(self)
+ mime = QMimeData()
+ mime.setText(str(self._panel._widgets.index(self._widget)))
+ drag.setMimeData(mime)
+ drag.exec(Qt.DropAction.MoveAction)
+ super().mousePressEvent(event)
+
+
class ControlWidget(QFrame):
"""Base class for all control panel widgets."""
@@ -84,18 +108,23 @@ class ControlWidget(QFrame):
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
- # Header bar
- hdr = QWidget()
+ # Header bar — also the drag handle; Edit/Remove buttons added on top by ControlPanel
+ hdr = _ControlHeader()
hdr.setObjectName("controlWidgetHeader")
hdr.setFixedHeight(26)
hdr_lay = QHBoxLayout(hdr)
- hdr_lay.setContentsMargins(8, 0, 8, 0)
+ hdr_lay.setContentsMargins(8, 0, 4, 0)
+ hdr_lay.setSpacing(4)
icon_lbl = QLabel(self.icon)
icon_lbl.setObjectName("controlWidgetIcon")
+ icon_lbl.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
title_lbl = QLabel(self.title.upper())
title_lbl.setObjectName("controlWidgetTitle")
+ title_lbl.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
hdr_lay.addWidget(icon_lbl)
hdr_lay.addWidget(title_lbl, 1)
+ self._header = hdr
+ self._header_layout = hdr_lay
outer.addWidget(hdr)
# Body — subclasses populate this
@@ -547,13 +576,61 @@ class AnalogOutputControl(ControlWidget):
# ══════════════════════════════════════════════════════════════════════════════
+# Drag-to-reorder helper
+# ══════════════════════════════════════════════════════════════════════════════
+
+class _DropContainer(QWidget):
+ """Holds the stacked control widgets; drops reorder them via the panel."""
+
+ def __init__(self, panel: "ControlPanel"):
+ super().__init__()
+ self._panel = panel
+ 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._panel._reorder(src_idx, self._panel._drop_index(event.position().y()))
+ event.acceptProposedAction()
+
+ def _show_indicator(self, y: float):
+ self._indicator_y = self._panel._indicator_y_for(self._panel._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()
+
+
+# ══════════════════════════════════════════════════════════════════════════════
# Control Panel container
# ══════════════════════════════════════════════════════════════════════════════
class ControlPanel(QWidget):
"""
Left panel — output control widgets.
- Header has Add button. Each widget has Edit, Remove, and reorder buttons.
+ Header has Add button. Each widget has Edit, Remove, and drag-to-reorder.
"""
controls_changed = pyqtSignal() # emitted whenever widgets are added/edited/removed
@@ -578,7 +655,7 @@ class ControlPanel(QWidget):
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
scroll.setObjectName("deviceScroll")
- self._container = QWidget()
+ self._container = _DropContainer(self)
self._inner = QVBoxLayout(self._container)
self._inner.setContentsMargins(6, 6, 6, 6)
self._inner.setSpacing(8)
@@ -597,50 +674,32 @@ class ControlPanel(QWidget):
except Exception as e:
print(f"[Control '{w.title}'] safe_stop failed: {e}")
- def _make_wrapper(self, widget: ControlWidget, spec) -> QFrame:
- """Wrap a ControlWidget with Edit / Remove / reorder buttons."""
- wrapper = QFrame(); wrapper.setObjectName("controlWidgetWrapper")
- wl = QVBoxLayout(wrapper); wl.setContentsMargins(0, 0, 0, 0); wl.setSpacing(0)
- wl.addWidget(widget)
-
- # Button row below each widget
- btn_row = QHBoxLayout(); btn_row.setContentsMargins(2, 1, 2, 1)
-
- up_btn = QPushButton("↑"); up_btn.setObjectName("traceRemoveBtn")
- up_btn.setFixedSize(20, 20); up_btn.setToolTip("Move up")
- dn_btn = QPushButton("↓"); dn_btn.setObjectName("traceRemoveBtn")
- dn_btn.setFixedSize(20, 20); dn_btn.setToolTip("Move down")
-
- up_btn.clicked.connect(lambda: self._move(spec, -1))
- dn_btn.clicked.connect(lambda: self._move(spec, +1))
-
- btn_row.addWidget(up_btn)
- btn_row.addWidget(dn_btn)
- btn_row.addStretch()
+ def _wire_widget(self, widget: ControlWidget, spec):
+ """Hook up the widget's header as a drag source and add Edit/Remove
+ buttons top-right in that same header — the whole bar drags to
+ reorder except where these two buttons intercept the click."""
+ widget._header.set_drag_source(self, widget)
edit_btn = QPushButton("✎ Edit"); edit_btn.setObjectName("configButton")
edit_btn.setFixedHeight(20)
rm_btn = QPushButton("✕"); rm_btn.setObjectName("traceRemoveBtn")
- rm_btn.setFixedSize(20, 20)
+ rm_btn.setFixedSize(20, 20); rm_btn.setToolTip("Remove")
- edit_btn.clicked.connect(lambda: self._on_edit(widget, spec, wrapper))
- rm_btn.clicked.connect( lambda: self._on_remove(widget, spec, wrapper))
+ edit_btn.clicked.connect(lambda: self._on_edit(widget, spec))
+ rm_btn.clicked.connect( lambda: self._on_remove(widget, spec))
- btn_row.addWidget(edit_btn)
- btn_row.addWidget(rm_btn)
- wl.addLayout(btn_row)
- return wrapper
+ widget._header_layout.addWidget(edit_btn)
+ widget._header_layout.addWidget(rm_btn)
def _add_widget_from_spec(self, spec):
"""Instantiate a ControlWidget from a ControlSpec and add to panel."""
- from ui.control_editor import ControlSpec as CS
widget = _build_widget_from_spec(spec, self.registry, self.processor)
if widget is None:
return
- wrapper = self._make_wrapper(widget, spec)
+ self._wire_widget(widget, spec)
self._widgets.append(widget)
self._specs.append(spec)
- self._inner.insertWidget(self._inner.count() - 1, wrapper)
+ self._inner.insertWidget(self._inner.count() - 1, widget)
self.controls_changed.emit()
def add_widget(self, widget: ControlWidget, spec=None):
@@ -652,10 +711,10 @@ class ControlPanel(QWidget):
device_id=widget.device_id, channel_id=widget.channel_id,
active_low=getattr(widget, "active_low", False),
)
- wrapper = self._make_wrapper(widget, spec)
+ self._wire_widget(widget, spec)
self._widgets.append(widget)
self._specs.append(spec)
- self._inner.insertWidget(self._inner.count() - 1, wrapper)
+ self._inner.insertWidget(self._inner.count() - 1, widget)
def clear_widgets(self):
while self._inner.count() > 1:
@@ -675,22 +734,45 @@ class ControlPanel(QWidget):
for spec in specs:
self._add_widget_from_spec(spec)
- def _move(self, spec, direction: int):
- """Move a control up (-1) or down (+1) in the list."""
- try:
- idx = self._specs.index(spec)
- except ValueError:
- return
- new_idx = idx + direction
- if new_idx < 0 or new_idx >= len(self._specs):
+ def _drop_index(self, y: float) -> int:
+ """Insertion index for a drop at container-relative y."""
+ n = self._inner.count() - 1 # exclude trailing stretch
+ for i in range(n):
+ w = self._inner.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._inner.count() - 1 # exclude trailing stretch
+ spacing = self._inner.spacing()
+ if n == 0:
+ return self._inner.contentsMargins().top()
+ if idx <= 0:
+ w = self._inner.itemAt(0).widget()
+ return w.y() - spacing // 2
+ if idx >= n:
+ w = self._inner.itemAt(n - 1).widget()
+ return w.y() + w.height() + spacing // 2
+ prev = self._inner.itemAt(idx - 1).widget()
+ nxt = self._inner.itemAt(idx).widget()
+ return (prev.y() + prev.height() + nxt.y()) // 2
+
+ def _reorder(self, src_idx: int, dst_idx: int):
+ """Move the widget at src_idx to dst_idx (drop-target index, pre-removal)."""
+ if dst_idx > src_idx:
+ dst_idx -= 1
+ if dst_idx == src_idx:
return
- self._specs.insert(new_idx, self._specs.pop(idx))
- self._widgets.insert(new_idx, self._widgets.pop(idx))
+ spec = self._specs.pop(src_idx)
+ widget = self._widgets.pop(src_idx)
+ self._specs.insert(dst_idx, spec)
+ self._widgets.insert(dst_idx, widget)
- wrapper = self._inner.itemAt(idx).widget()
- self._inner.removeWidget(wrapper)
- self._inner.insertWidget(new_idx, wrapper)
+ self._inner.removeWidget(widget)
+ self._inner.insertWidget(dst_idx, widget)
self.controls_changed.emit()
@@ -702,7 +784,7 @@ class ControlPanel(QWidget):
if dlg.exec() and dlg.result_spec:
self._add_widget_from_spec(dlg.result_spec)
- def _on_edit(self, widget: ControlWidget, spec, wrapper: QFrame):
+ def _on_edit(self, widget: ControlWidget, spec):
from ui.control_editor import ControlEditorDialog
dlg = ControlEditorDialog(self.registry, spec=spec, parent=self)
if not (dlg.exec() and dlg.result_spec):
@@ -710,23 +792,23 @@ class ControlPanel(QWidget):
new_spec = dlg.result_spec
idx = self._specs.index(spec)
- # Remove old wrapper
- self._inner.removeWidget(wrapper); wrapper.deleteLater()
+ # Remove old widget
+ self._inner.removeWidget(widget); widget.deleteLater()
self._widgets.pop(idx); self._specs.pop(idx)
# Insert new one at same position
new_widget = _build_widget_from_spec(new_spec, self.registry, self.processor)
if new_widget is None:
return
- new_wrapper = self._make_wrapper(new_widget, new_spec)
+ self._wire_widget(new_widget, new_spec)
self._widgets.insert(idx, new_widget)
self._specs.insert(idx, new_spec)
- self._inner.insertWidget(idx, new_wrapper)
+ self._inner.insertWidget(idx, new_widget)
self.controls_changed.emit()
- def _on_remove(self, widget: ControlWidget, spec, wrapper: QFrame):
+ def _on_remove(self, widget: ControlWidget, spec):
idx = self._specs.index(spec)
- self._inner.removeWidget(wrapper); wrapper.deleteLater()
+ self._inner.removeWidget(widget); widget.deleteLater()
self._widgets.pop(idx); self._specs.pop(idx)
self.controls_changed.emit()