diff options
Diffstat (limited to 'ui')
| -rw-r--r-- | ui/control_panel.py | 200 | ||||
| -rw-r--r-- | ui/style_dark.qss | 6 | ||||
| -rw-r--r-- | ui/style_light.qss | 1 |
3 files changed, 141 insertions, 66 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() diff --git a/ui/style_dark.qss b/ui/style_dark.qss index 146c162..cfb127d 100644 --- a/ui/style_dark.qss +++ b/ui/style_dark.qss @@ -562,12 +562,6 @@ QMenu#profileMenu::separator { margin: 3px 8px; } -/* ── Control widget wrapper (edit/remove row) ────────────────────── */ -QFrame#controlWidgetWrapper { - background: transparent; - border: none; -} - /* ── Layout canvas ───────────────────────────────────────────────── */ QWidget#layoutCanvasBar { background-color: #0f1521; diff --git a/ui/style_light.qss b/ui/style_light.qss index 19d8025..b98dc52 100644 --- a/ui/style_light.qss +++ b/ui/style_light.qss @@ -77,7 +77,6 @@ QWidget#controlWidgetHeader { background:#f1f5f9; border-radius:5px 5px 0 0; bor QLabel#controlWidgetIcon { font-size:14px; color:#3b82f6; padding-right:4px; } QLabel#controlWidgetTitle { font-family:"IBM Plex Mono",monospace; font-size:11px; font-weight:700; letter-spacing:1.5px; color:#64748b; } QWidget#controlWidgetBody { background:#ffffff; border-radius:0 0 5px 5px; } -QFrame#controlWidgetWrapper { background:transparent; border:none; } /* Motor */ QLabel#motorSpeedLbl { font-family:"IBM Plex Mono",monospace; font-size:28px; font-weight:700; color:#1d4ed8; } |
