wip drag&drop

This commit is contained in:
2026-05-29 16:39:37 +02:00
parent eed259b93b
commit 2435d0172d
2 changed files with 255 additions and 7 deletions
@@ -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())
@@ -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)