From 2435d0172dcf3e6f19d1495159eaf0823d30be2c Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 29 May 2026 16:39:37 +0200 Subject: [PATCH] wip drag&drop --- .../beamline_states/beamline_state_pill.py | 210 +++++++++++++++++- tests/unit_tests/test_beamline_state_pill.py | 52 +++++ 2 files changed, 255 insertions(+), 7 deletions(-) diff --git a/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py b/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py index 76744d36..0940c248 100644 --- a/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py +++ b/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import re import sys from typing import Any @@ -7,8 +8,8 @@ from typing import Any from bec_lib import bl_states from bec_lib.endpoints import MessageEndpoints from bec_qthemes import material_icon -from qtpy.QtCore import QEvent, Qt, QTimer, Signal, Slot -from qtpy.QtGui import QColor, QPalette +from qtpy.QtCore import QByteArray, QEvent, QMimeData, QPoint, Qt, QTimer, Signal, Slot +from qtpy.QtGui import QColor, QDrag, QPalette from qtpy.QtWidgets import ( QApplication, QCheckBox, @@ -68,6 +69,12 @@ class BeamlineStatePill(BECWidget, QWidget): "unknown": "help", } _SETTINGS_FIELD_WIDTH = 280 + _VALID_DRAG_PAYLOAD_MODES = {"config", "device"} + MIME_PAYLOAD = "application/x-bec-beamline-state-payload" + MIME_CONFIG = "application/x-bec-beamline-state-config" + MIME_NAME = "application/x-bec-beamline-state-name" + MIME_DEVICE = "application/x-bec-beamline-state-device" + MIME_PAYLOAD_MODE = "application/x-bec-beamline-state-payload-mode" def __init__( self, @@ -89,6 +96,9 @@ class BeamlineStatePill(BECWidget, QWidget): self._label = "No state information available." self._flash_active = False self._expanded = False + self._drag_payload_mode = "config" + self._drag_start_position: QPoint | None = None + self._drag_started = False self._flash_timer = QTimer(self) self._flash_timer.setSingleShot(True) @@ -225,14 +235,44 @@ class BeamlineStatePill(BECWidget, QWidget): self.set_state_name(state_name, title=title) def eventFilter(self, watched: object, event: QEvent) -> bool: # noqa: N802 - if event.type() == QEvent.Type.MouseButtonRelease and watched in { + draggable_widgets = { self._header, self._stripe, self._icon_label, self._name_label, self._status_label, self._detail_label, - }: + } + if watched not in draggable_widgets: + return super().eventFilter(watched, event) + + if ( + event.type() == QEvent.Type.MouseButtonPress + and event.button() == Qt.MouseButton.LeftButton + ): + self._drag_start_position = self._event_position(event) + self._drag_started = False + return False + + if ( + event.type() == QEvent.Type.MouseMove + and self._drag_start_position is not None + and not self._drag_started + and event.buttons() & Qt.MouseButton.LeftButton + ): + distance = (self._event_position(event) - self._drag_start_position).manhattanLength() + if distance >= QApplication.startDragDistance(): + self._drag_started = True + self._drag_start_position = None + self._start_drag() + return True + + if event.type() == QEvent.Type.MouseButtonRelease: + if self._drag_started: + self._drag_started = False + self._drag_start_position = None + return True + self._drag_start_position = None self._toggle_expanded() return True return super().eventFilter(watched, event) @@ -277,6 +317,28 @@ class BeamlineStatePill(BECWidget, QWidget): self._state_config = state_config self._populate_settings() + @property + def drag_payload_mode(self) -> str: + """ + Payload mode used when dragging this pill. + + Supported values: + ``"config"``: drag the full state configuration dictionary. + ``"device"``: drag only the configured device name. + """ + return self._drag_payload_mode + + @drag_payload_mode.setter + def drag_payload_mode(self, mode: str) -> None: + if mode not in self._VALID_DRAG_PAYLOAD_MODES: + valid_modes = ", ".join(sorted(self._VALID_DRAG_PAYLOAD_MODES)) + raise ValueError(f"Invalid drag payload mode '{mode}'. Expected one of: {valid_modes}.") + self._drag_payload_mode = mode + + def set_drag_payload_mode(self, mode: str) -> None: + """Set the payload mode used for drag-and-drop.""" + self.drag_payload_mode = mode + def _refresh_latest_state(self) -> None: if self._state_name is None: return @@ -455,6 +517,45 @@ class BeamlineStatePill(BECWidget, QWidget): return self.remove_requested.emit(self._state_name) + def _start_drag(self) -> None: + if self._state_name is None: + return + drag = QDrag(self) + drag.setMimeData(self._create_drag_mime_data()) + pixmap = self.grab() + if not pixmap.isNull(): + drag.setPixmap( + pixmap.scaledToWidth( + min(pixmap.width(), 360), Qt.TransformationMode.SmoothTransformation + ) + ) + drag.exec(Qt.DropAction.CopyAction) + + def _create_drag_mime_data(self) -> QMimeData: + mime_data = QMimeData() + config_json = json.dumps(self._state_config, default=str) + payload = self.drag_payload() + payload_text = ( + json.dumps(payload, default=str) if isinstance(payload, dict) else str(payload) + ) + device = str(self._state_field("device") or "") + + mime_data.setData(self.MIME_CONFIG, QByteArray(config_json.encode("utf-8"))) + mime_data.setData(self.MIME_PAYLOAD, QByteArray(payload_text.encode("utf-8"))) + mime_data.setData(self.MIME_NAME, QByteArray((self._state_name or "").encode("utf-8"))) + mime_data.setData(self.MIME_DEVICE, QByteArray(device.encode("utf-8"))) + mime_data.setData( + self.MIME_PAYLOAD_MODE, QByteArray(self._drag_payload_mode.encode("utf-8")) + ) + mime_data.setText(payload_text) + return mime_data + + def drag_payload(self) -> dict[str, Any] | str: + """Return the currently configured drag payload.""" + if self._drag_payload_mode == "device": + return str(self._state_field("device") or "") + return self._state_config + def _state_field(self, name: str) -> Any: parameters = self._state_config.get("parameters") if isinstance(parameters, dict) and name in parameters: @@ -464,6 +565,12 @@ class BeamlineStatePill(BECWidget, QWidget): def _state_type(self) -> str: return str(self._state_config.get("state_type") or self._state_field("state_type") or "") + @staticmethod + def _event_position(event: QEvent) -> QPoint: + if hasattr(event, "position"): + return event.position().toPoint() + return event.pos() + def _on_settings_device_selected(self, device: str) -> None: self._signal_edit.set_device(device) @@ -914,7 +1021,16 @@ class BeamlineStateManager(BECWidget, QWidget): PLUGIN = True ICON_NAME = "format_list_bulleted" - USER_ACCESS = ["refresh_states", "clear_filters", "remove", "attach", "detach", "screenshot"] + USER_ACCESS = [ + "drag_payload_mode", + "set_drag_payload_mode", + "refresh_states", + "clear_filters", + "remove", + "attach", + "detach", + "screenshot", + ] def __init__( self, @@ -922,6 +1038,7 @@ class BeamlineStateManager(BECWidget, QWidget): client=None, config: ConnectionConfig | None = None, gui_id: str | None = None, + drag_payload_mode: str = "config", **kwargs, ) -> None: super().__init__( @@ -934,6 +1051,8 @@ class BeamlineStateManager(BECWidget, QWidget): self._selected_devices: set[str] | None = None self._device_filter_text = "" self._hidden_expanded = False + self._drag_payload_mode = "config" + self.drag_payload_mode = drag_payload_mode self._empty_label = QLabel( "No beamline states available.\n Add new state from toolbar or CLI.", self @@ -981,6 +1100,30 @@ class BeamlineStateManager(BECWidget, QWidget): self.refresh_states() self._refresh_hidden_summary() + @property + def drag_payload_mode(self) -> str: + """ + Payload mode used by pills dragged from this manager. + + Supported values: + ``"config"``: drag the full state configuration dictionary. + ``"device"``: drag only the configured device name. + """ + return self._drag_payload_mode + + @drag_payload_mode.setter + def drag_payload_mode(self, mode: str) -> None: + if mode not in BeamlineStatePill._VALID_DRAG_PAYLOAD_MODES: + valid_modes = ", ".join(sorted(BeamlineStatePill._VALID_DRAG_PAYLOAD_MODES)) + raise ValueError(f"Invalid drag payload mode '{mode}'. Expected one of: {valid_modes}.") + self._drag_payload_mode = mode + for pill in self._state_pills.values(): + pill.drag_payload_mode = mode + + def set_drag_payload_mode(self, mode: str) -> None: + """Set the payload mode used for drag-and-drop.""" + self.drag_payload_mode = mode + def _create_toolbar(self) -> ModularToolBar: toolbar = ModularToolBar(parent=self) @@ -1129,6 +1272,7 @@ class BeamlineStateManager(BECWidget, QWidget): pill = BeamlineStatePill( parent=self._content, state_name=name, title=title, client=self.client ) + pill.drag_payload_mode = self._drag_payload_mode pill.set_state_config(state_config) pill.state_changed.connect(self._on_pill_state_changed) pill.update_requested.connect(self._update_state_parameters) @@ -1306,9 +1450,40 @@ if __name__ == "__main__": # pragma: no cover from bec_widgets.utils.colors import apply_theme from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton + from qtpy.QtWidgets import QAbstractItemView, QListWidget apply_theme("dark") + class BeamlineStateDropList(QListWidget): + """Example drop target for beamline state drag payloads.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setAcceptDrops(True) + self.setDragDropMode(QAbstractItemView.DragDropMode.DropOnly) + + def dragEnterEvent(self, event) -> None: # noqa: N802 + if event.mimeData().hasFormat(BeamlineStatePill.MIME_PAYLOAD): + event.acceptProposedAction() + return + super().dragEnterEvent(event) + + def dragMoveEvent(self, event) -> None: # noqa: N802 + if event.mimeData().hasFormat(BeamlineStatePill.MIME_PAYLOAD): + event.acceptProposedAction() + return + super().dragMoveEvent(event) + + def dropEvent(self, event) -> None: # noqa: N802 + mime_data = event.mimeData() + if not mime_data.hasFormat(BeamlineStatePill.MIME_PAYLOAD): + super().dropEvent(event) + return + mode = bytes(mime_data.data(BeamlineStatePill.MIME_PAYLOAD_MODE)).decode("utf-8") + payload = bytes(mime_data.data(BeamlineStatePill.MIME_PAYLOAD)).decode("utf-8") + self.addItem(f"{mode}: {payload}") + event.acceptProposedAction() + window = QWidget() window.setWindowTitle("Beamline States") layout = QVBoxLayout(window) @@ -1319,8 +1494,29 @@ if __name__ == "__main__": # pragma: no cover theme_row.addStretch(1) theme_row.addWidget(DarkModeButton(parent=window)) layout.addLayout(theme_row) - layout.addWidget(BeamlineStateManager(parent=window)) - window.resize(420, 480) + manager = BeamlineStateManager(parent=window) + mode_combo = QComboBox(window) + mode_combo.addItem("Drag full config", "config") + mode_combo.addItem("Drag device name", "device") + mode_combo.currentIndexChanged.connect( + lambda: setattr(manager, "drag_payload_mode", mode_combo.currentData()) + ) + + drop_list = BeamlineStateDropList(window) + drop_list.setMinimumWidth(260) + + payload_row = QHBoxLayout() + payload_row.addWidget(QLabel("Drag payload", window)) + payload_row.addWidget(mode_combo) + payload_row.addStretch(1) + layout.addLayout(payload_row) + + content_row = QHBoxLayout() + content_row.addWidget(manager, 2) + content_row.addWidget(drop_list, 1) + layout.addLayout(content_row) + + window.resize(760, 480) window.show() sys.exit(app.exec()) diff --git a/tests/unit_tests/test_beamline_state_pill.py b/tests/unit_tests/test_beamline_state_pill.py index 6f9073da..5f6f5bce 100644 --- a/tests/unit_tests/test_beamline_state_pill.py +++ b/tests/unit_tests/test_beamline_state_pill.py @@ -1,3 +1,5 @@ +import json + import shiboken6 from bec_lib import messages from qtpy.QtCore import QCoreApplication, QEvent, Qt @@ -90,6 +92,31 @@ def test_beamline_state_pill_expands_and_emits_updated_limits(qtbot, mocked_clie assert signal.args[1]["tolerance"] == 0.1 +def test_beamline_state_pill_drag_payload_modes(qtbot, mocked_client): + widget = BeamlineStatePill(state_name="limits", title="Limits", client=mocked_client) + qtbot.addWidget(widget) + widget.set_state_config( + { + "name": "limits", + "title": "Limits", + "state_type": "DeviceWithinLimitsState", + "parameters": {"device": "samx", "signal": "samx"}, + } + ) + + config_mime = widget._create_drag_mime_data() + config_payload = bytes(config_mime.data(BeamlineStatePill.MIME_PAYLOAD)).decode("utf-8") + assert json.loads(config_payload)["parameters"]["device"] == "samx" + assert config_mime.text() == config_payload + + widget.drag_payload_mode = "device" + device_mime = widget._create_drag_mime_data() + + assert bytes(device_mime.data(BeamlineStatePill.MIME_PAYLOAD)).decode("utf-8") == "samx" + assert bytes(device_mime.data(BeamlineStatePill.MIME_DEVICE)).decode("utf-8") == "samx" + assert device_mime.text() == "samx" + + def test_beamline_state_manager_adds_and_removes_pills(qtbot, mocked_client): widget = BeamlineStateManager(client=mocked_client) qtbot.addWidget(widget) @@ -132,6 +159,31 @@ def test_beamline_state_manager_adds_and_removes_pills(qtbot, mocked_client): assert sorted(widget._state_pills) == ["limits"] +def test_beamline_state_manager_propagates_drag_payload_mode(qtbot, mocked_client): + widget = BeamlineStateManager(client=mocked_client, drag_payload_mode="device") + qtbot.addWidget(widget) + + widget.update_available_states( + { + "states": [ + { + "name": "limits", + "title": "Limits", + "state_type": "DeviceWithinLimitsState", + "parameters": {"device": "samx"}, + } + ] + }, + {}, + ) + + assert widget._state_pills["limits"].drag_payload_mode == "device" + + widget.drag_payload_mode = "config" + + assert widget._state_pills["limits"].drag_payload_mode == "config" + + def test_beamline_state_manager_filters_status(qtbot, mocked_client): widget = BeamlineStateManager(client=mocked_client) qtbot.addWidget(widget)