wip safeslot safeproperty migration

This commit is contained in:
2026-05-29 17:01:41 +02:00
parent 4e85fb6a74
commit 2dedc47b56
2 changed files with 78 additions and 76 deletions
@@ -8,7 +8,7 @@ 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 QByteArray, QEvent, QMimeData, QPoint, Qt, QTimer, Signal, Slot
from qtpy.QtCore import QByteArray, QEvent, QMimeData, QPoint, Qt, QTimer, Signal
from qtpy.QtGui import QColor, QCursor, QDrag, QPalette
from qtpy.QtWidgets import (
QApplication,
@@ -33,6 +33,7 @@ from qtpy.QtWidgets import (
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
@@ -41,6 +42,12 @@ from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox im
from bec_widgets.widgets.utility.spinbox.decimal_spinbox import BECSpinBox
def _coerce_bool(value: Any) -> bool:
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
class BeamlineStatePill(BECWidget, QWidget):
"""
Compact widget showing one BEC beamline state.
@@ -71,7 +78,6 @@ class BeamlineStatePill(BECWidget, QWidget):
}
_SETTINGS_FIELD_WIDTH = 280
_VALID_DRAG_PAYLOAD_MODES = {"config", "device"}
_VALID_CARD_BACKGROUND_MODES = {"hover", "always"}
MIME_PAYLOAD = "application/x-bec-beamline-state-payload"
MIME_CONFIG = "application/x-bec-beamline-state-config"
MIME_NAME = "application/x-bec-beamline-state-name"
@@ -101,7 +107,7 @@ class BeamlineStatePill(BECWidget, QWidget):
self._flash_active = False
self._expanded = False
self._hovered = False
self._card_background_mode = "hover"
self._idle_card_background = False
self._drag_payload_mode = "config"
self._drag_start_position: QPoint | None = None
self._drag_started = False
@@ -301,11 +307,15 @@ class BeamlineStatePill(BECWidget, QWidget):
QTimer.singleShot(0, self._sync_hover_state)
super().leaveEvent(event)
@property
@SafeProperty(str, default=None)
def state_name(self) -> str | None:
"""Name of the BEC beamline state displayed by this pill."""
return self._state_name
@state_name.setter
def state_name(self, state_name: str | None) -> None:
self.set_state_name(state_name)
def set_state_name(self, state_name: str | None, title: str | None = None) -> None:
"""
Set the BEC beamline state this pill displays.
@@ -341,7 +351,7 @@ class BeamlineStatePill(BECWidget, QWidget):
self._state_config = state_config
self._populate_settings()
@property
@SafeProperty(str)
def drag_payload_mode(self) -> str:
"""
Payload mode used when dragging this pill.
@@ -363,30 +373,21 @@ class BeamlineStatePill(BECWidget, QWidget):
"""Set the payload mode used for drag-and-drop."""
self.drag_payload_mode = mode
@property
def card_background_mode(self) -> str:
@SafeProperty(bool, default=False)
def idle_card_background(self) -> bool:
"""
Background mode for the pill card.
Supported values:
``"hover"``: idle pills are transparent; hover/expanded pills use a card background.
``"always"``: idle pills keep the status-tinted background.
Whether idle collapsed pills keep the status-tinted card background.
"""
return self._card_background_mode
return self._idle_card_background
@card_background_mode.setter
def card_background_mode(self, mode: str) -> None:
if mode not in self._VALID_CARD_BACKGROUND_MODES:
valid_modes = ", ".join(sorted(self._VALID_CARD_BACKGROUND_MODES))
raise ValueError(
f"Invalid card background mode '{mode}'. Expected one of: {valid_modes}."
)
self._card_background_mode = mode
@idle_card_background.setter
def idle_card_background(self, enabled: bool) -> None:
self._idle_card_background = _coerce_bool(enabled)
self._apply_visual_state()
def set_card_background_mode(self, mode: str) -> None:
"""Set when the pill card background should be shown."""
self.card_background_mode = mode
def set_idle_card_background(self, enabled: bool) -> None:
"""Set whether idle collapsed pills keep the status-tinted card background."""
self.idle_card_background = enabled
def _refresh_latest_state(self) -> None:
if self._state_name is None:
@@ -401,7 +402,7 @@ class BeamlineStatePill(BECWidget, QWidget):
if isinstance(content, dict):
self.update_state(content, getattr(data, "metadata", {}))
@Slot(dict, dict)
@SafeSlot(dict, dict)
def update_state(
self, content: dict[str, Any], _metadata: dict[str, Any] | None = None
) -> None:
@@ -418,7 +419,7 @@ class BeamlineStatePill(BECWidget, QWidget):
self._set_visual_state(status, label, flash=status_changed)
self.state_changed.emit(self._state_name or str(name or ""), status, label)
@Slot(str)
@SafeSlot(str)
def apply_theme(self, _theme: str) -> None:
self._apply_visual_state()
@@ -438,7 +439,7 @@ class BeamlineStatePill(BECWidget, QWidget):
accent = colors["accent"]
on_accent = colors["on_accent"]
active_card = self._hovered or self._expanded
show_idle_background = self._card_background_mode == "always" or self._flash_active
show_idle_background = self._idle_card_background or self._flash_active
border = (
colors["flash_border"]
if self._flash_active
@@ -517,6 +518,7 @@ class BeamlineStatePill(BECWidget, QWidget):
"}"
)
@SafeSlot()
def _toggle_expanded(self) -> None:
self._expanded = not self._expanded
self._settings.setVisible(self._expanded)
@@ -528,6 +530,7 @@ class BeamlineStatePill(BECWidget, QWidget):
self._hovered = hovered
self._apply_visual_state()
@SafeSlot()
def _sync_hover_state(self) -> None:
inside = self.rect().contains(self.mapFromGlobal(QCursor.pos()))
self._set_hovered(inside)
@@ -586,6 +589,7 @@ class BeamlineStatePill(BECWidget, QWidget):
)
return params
@SafeSlot()
def _emit_update_requested(self) -> None:
if self._state_name is None:
return
@@ -596,6 +600,7 @@ class BeamlineStatePill(BECWidget, QWidget):
return
self.update_requested.emit(self._state_name, parameters)
@SafeSlot()
def _emit_remove_requested(self) -> None:
if self._state_name is None:
return
@@ -655,9 +660,11 @@ class BeamlineStatePill(BECWidget, QWidget):
return event.position().toPoint()
return event.pos()
@SafeSlot(str)
def _on_settings_device_selected(self, device: str) -> None:
self._signal_edit.set_device(device)
@SafeSlot()
def _on_settings_device_reset(self) -> None:
self._signal_edit.set_device(None)
@@ -757,6 +764,7 @@ class BeamlineStatePill(BECWidget, QWidget):
if label is not None:
label.setVisible(visible)
@SafeSlot()
def _clear_state_flash(self) -> None:
self._flash_active = False
self._apply_visual_state()
@@ -949,6 +957,7 @@ class AddBeamlineStateDialog(QDialog):
self._signal.close()
self._signal.deleteLater()
@SafeSlot(str)
def _on_valid_device_selected(self, device: str) -> None:
if self._cleaned_up:
return
@@ -960,12 +969,14 @@ class AddBeamlineStateDialog(QDialog):
self._auto_generated_name = generated_name
self._name.setText(generated_name)
@SafeSlot()
def _on_device_reset(self) -> None:
if self._cleaned_up:
return
self._signal.set_device(None)
def _update_field_visibility(self) -> None:
@SafeSlot(int)
def _update_field_visibility(self, _index: int = 0) -> None:
show_limits = self._type_combo.currentData() == "device_within_limits"
for widget in (self._low_limit, self._high_limit, self._tolerance):
widget.setVisible(show_limits)
@@ -1145,8 +1156,8 @@ class BeamlineStateManager(BECWidget, QWidget):
USER_ACCESS = [
"drag_payload_mode",
"set_drag_payload_mode",
"card_background_mode",
"set_card_background_mode",
"idle_card_background",
"set_idle_card_background",
"refresh_states",
"clear_filters",
"remove",
@@ -1162,7 +1173,7 @@ class BeamlineStateManager(BECWidget, QWidget):
config: ConnectionConfig | None = None,
gui_id: str | None = None,
drag_payload_mode: str = "config",
card_background_mode: str = "hover",
idle_card_background: bool = False,
**kwargs,
) -> None:
super().__init__(
@@ -1176,9 +1187,9 @@ class BeamlineStateManager(BECWidget, QWidget):
self._device_filter_text = ""
self._hidden_expanded = False
self._drag_payload_mode = "config"
self._card_background_mode = "hover"
self._idle_card_background = False
self.drag_payload_mode = drag_payload_mode
self.card_background_mode = card_background_mode
self.idle_card_background = idle_card_background
self._empty_label = QLabel(
"No beamline states available.\n Add new state from toolbar or CLI.", self
@@ -1226,7 +1237,7 @@ class BeamlineStateManager(BECWidget, QWidget):
self.refresh_states()
self._refresh_hidden_summary()
@property
@SafeProperty(str)
def drag_payload_mode(self) -> str:
"""
Payload mode used by pills dragged from this manager.
@@ -1250,31 +1261,22 @@ class BeamlineStateManager(BECWidget, QWidget):
"""Set the payload mode used for drag-and-drop."""
self.drag_payload_mode = mode
@property
def card_background_mode(self) -> str:
@SafeProperty(bool, default=False)
def idle_card_background(self) -> bool:
"""
Background mode used by pills in this manager.
Supported values:
``"hover"``: idle pills are transparent; hover/expanded pills use a card background.
``"always"``: idle pills keep the status-tinted background.
Whether idle collapsed pills keep the status-tinted card background.
"""
return self._card_background_mode
return self._idle_card_background
@card_background_mode.setter
def card_background_mode(self, mode: str) -> None:
if mode not in BeamlineStatePill._VALID_CARD_BACKGROUND_MODES:
valid_modes = ", ".join(sorted(BeamlineStatePill._VALID_CARD_BACKGROUND_MODES))
raise ValueError(
f"Invalid card background mode '{mode}'. Expected one of: {valid_modes}."
)
self._card_background_mode = mode
@idle_card_background.setter
def idle_card_background(self, enabled: bool) -> None:
self._idle_card_background = _coerce_bool(enabled)
for pill in self._state_pills.values():
pill.card_background_mode = mode
pill.idle_card_background = self._idle_card_background
def set_card_background_mode(self, mode: str) -> None:
"""Set when pill card backgrounds should be shown."""
self.card_background_mode = mode
def set_idle_card_background(self, enabled: bool) -> None:
"""Set whether idle collapsed pills keep the status-tinted card background."""
self.idle_card_background = enabled
def _create_toolbar(self) -> ModularToolBar:
toolbar = ModularToolBar(parent=self)
@@ -1315,7 +1317,7 @@ class BeamlineStateManager(BECWidget, QWidget):
toolbar.show_bundles(["beamline_state_manager"])
return toolbar
@Slot(str)
@SafeSlot(str)
def apply_theme(self, _theme: str) -> None:
colors = BeamlineStatePill._state_colors("unknown")
self.setStyleSheet(
@@ -1332,7 +1334,7 @@ class BeamlineStateManager(BECWidget, QWidget):
pill.apply_theme(_theme)
self._refresh_hidden_summary()
@Slot()
@SafeSlot()
def open_add_state_dialog(self) -> None:
dialog = AddBeamlineStateDialog(self, client=self.client)
config = None
@@ -1357,7 +1359,7 @@ class BeamlineStateManager(BECWidget, QWidget):
except Exception as exc:
QMessageBox.warning(self, "Cannot Add State", str(exc))
@Slot()
@SafeSlot()
def open_status_filter_dialog(self) -> None:
dialog = StatusFilterDialog(self._selected_statuses, self)
if dialog.exec() != QDialog.Accepted:
@@ -1365,7 +1367,7 @@ class BeamlineStateManager(BECWidget, QWidget):
self._selected_statuses = dialog.selected_statuses()
self._apply_filters()
@Slot()
@SafeSlot()
def open_device_filter_dialog(self) -> None:
dialog = DeviceFilterDialog(
self._available_devices(), self._selected_devices, self._device_filter_text, self
@@ -1376,7 +1378,7 @@ class BeamlineStateManager(BECWidget, QWidget):
self._device_filter_text = dialog.filter_text()
self._apply_filters()
@Slot()
@SafeSlot()
def clear_filters(self) -> None:
self._selected_statuses = None
self._selected_devices = None
@@ -1384,6 +1386,7 @@ class BeamlineStateManager(BECWidget, QWidget):
self._hidden_expanded = False
self._apply_filters()
@SafeSlot()
def refresh_states(self) -> None:
"""Fetch the latest cached available beamline states and update the list immediately."""
msg_container = self.client.connector.get_last(MessageEndpoints.available_beamline_states())
@@ -1394,7 +1397,7 @@ class BeamlineStateManager(BECWidget, QWidget):
if isinstance(content, dict):
self.update_available_states(content, getattr(data, "metadata", {}))
@Slot(dict, dict)
@SafeSlot(dict, dict)
def update_available_states(
self, content: dict[str, Any], _metadata: dict[str, Any] | None = None
) -> None:
@@ -1425,7 +1428,7 @@ class BeamlineStateManager(BECWidget, QWidget):
parent=self._content, state_name=name, title=title, client=self.client
)
pill.drag_payload_mode = self._drag_payload_mode
pill.card_background_mode = self._card_background_mode
pill.idle_card_background = self._idle_card_background
pill.set_state_config(state_config)
pill.state_changed.connect(self._on_pill_state_changed)
pill.update_requested.connect(self._update_state_parameters)
@@ -1499,11 +1502,12 @@ class BeamlineStateManager(BECWidget, QWidget):
return False
return True
@SafeSlot(str, str, str)
def _on_pill_state_changed(self, _name: str, _status: str, _label: str) -> None:
if self._selected_statuses is not None:
self._apply_filters()
@Slot(str, dict)
@SafeSlot(str, dict)
def _update_state_parameters(self, state_name: str, parameters: dict[str, Any]) -> None:
beamline_states = getattr(self.client, "beamline_states", None)
state_client = getattr(beamline_states, state_name, None) if beamline_states else None
@@ -1517,7 +1521,7 @@ class BeamlineStateManager(BECWidget, QWidget):
except Exception as exc:
QMessageBox.warning(self, "Cannot Update State", str(exc))
@Slot(str)
@SafeSlot(str)
def _remove_state_requested(self, state_name: str) -> None:
reply = QMessageBox.question(
self,
@@ -1540,6 +1544,7 @@ class BeamlineStateManager(BECWidget, QWidget):
except Exception as exc:
QMessageBox.warning(self, "Cannot Remove State", str(exc))
@SafeSlot(bool)
def _toggle_hidden_states(self, checked: bool) -> None:
self._hidden_expanded = checked
self._apply_filters()
@@ -1655,11 +1660,9 @@ if __name__ == "__main__": # pragma: no cover
mode_combo.currentIndexChanged.connect(
lambda: setattr(manager, "drag_payload_mode", mode_combo.currentData())
)
background_combo = QComboBox(window)
background_combo.addItem("Card on hover", "hover")
background_combo.addItem("Card always", "always")
background_combo.currentIndexChanged.connect(
lambda: setattr(manager, "card_background_mode", background_combo.currentData())
idle_background = QCheckBox("Idle card background", window)
idle_background.toggled.connect(
lambda checked: setattr(manager, "idle_card_background", checked)
)
drop_list = BeamlineStateDropList(window)
@@ -1668,8 +1671,7 @@ if __name__ == "__main__": # pragma: no cover
payload_row = QHBoxLayout()
payload_row.addWidget(QLabel("Drag payload", window))
payload_row.addWidget(mode_combo)
payload_row.addWidget(QLabel("Card background", window))
payload_row.addWidget(background_combo)
payload_row.addWidget(idle_background)
payload_row.addStretch(1)
layout.addLayout(payload_row)
+6 -6
View File
@@ -111,7 +111,7 @@ def test_beamline_state_pill_can_keep_idle_background(qtbot, mocked_client):
assert "#BeamlineStatePill {background: transparent" in widget.styleSheet()
widget.card_background_mode = "always"
widget.idle_card_background = True
assert "#BeamlineStatePill {background: transparent" not in widget.styleSheet()
assert "qlineargradient" not in widget.styleSheet()
@@ -221,8 +221,8 @@ def test_beamline_state_manager_propagates_drag_payload_mode(qtbot, mocked_clien
assert widget._state_pills["limits"].drag_payload_mode == "config"
def test_beamline_state_manager_propagates_card_background_mode(qtbot, mocked_client):
widget = BeamlineStateManager(client=mocked_client, card_background_mode="always")
def test_beamline_state_manager_propagates_idle_card_background(qtbot, mocked_client):
widget = BeamlineStateManager(client=mocked_client, idle_card_background=True)
qtbot.addWidget(widget)
widget.update_available_states(
@@ -239,11 +239,11 @@ def test_beamline_state_manager_propagates_card_background_mode(qtbot, mocked_cl
{},
)
assert widget._state_pills["limits"].card_background_mode == "always"
assert widget._state_pills["limits"].idle_card_background is True
widget.card_background_mode = "hover"
widget.idle_card_background = False
assert widget._state_pills["limits"].card_background_mode == "hover"
assert widget._state_pills["limits"].idle_card_background is False
def test_beamline_state_manager_filters_status(qtbot, mocked_client):