mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-06-05 21:08:40 +02:00
wip drag&drop
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user