wip grid design

This commit is contained in:
2026-05-29 16:25:02 +02:00
parent 67aa66edc9
commit 227b49e752
2 changed files with 461 additions and 14 deletions
@@ -7,7 +7,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 Qt, QTimer, Signal, Slot
from qtpy.QtCore import QEvent, Qt, QTimer, Signal, Slot
from qtpy.QtGui import QColor, QPalette
from qtpy.QtWidgets import (
QApplication,
@@ -16,6 +16,7 @@ from qtpy.QtWidgets import (
QDialog,
QDialogButtonBox,
QFormLayout,
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
@@ -52,6 +53,8 @@ class BeamlineStatePill(BECWidget, QWidget):
USER_ACCESS = ["state_name", "set_state_name", "remove", "attach", "detach", "screenshot"]
state_changed = Signal(str, str, str)
update_requested = Signal(str, dict)
remove_requested = Signal(str)
_STATUS_LABELS = {
"valid": "VALID",
@@ -81,14 +84,20 @@ class BeamlineStatePill(BECWidget, QWidget):
)
self._state_name: str | None = None
self._title: str | None = None
self._state_config: dict[str, Any] = {}
self._status = "unknown"
self._label = "No state information available."
self._flash_active = False
self._expanded = False
self._flash_timer = QTimer(self)
self._flash_timer.setSingleShot(True)
self._flash_timer.timeout.connect(self._clear_state_flash)
self._header = QWidget(self)
self._header.setObjectName("beamline_state_header")
self._header.setCursor(Qt.CursorShape.PointingHandCursor)
self._stripe = QWidget(self)
self._stripe.setObjectName("beamline_state_stripe")
self._stripe.setFixedWidth(4)
@@ -108,6 +117,11 @@ class BeamlineStatePill(BECWidget, QWidget):
self._detail_label.setObjectName("beamline_state_detail")
self._detail_label.setTextFormat(Qt.TextFormat.PlainText)
self._detail_label.setWordWrap(True)
self._expand_button = QToolButton(self)
self._expand_button.setObjectName("beamline_state_expand")
self._expand_button.setAutoRaise(True)
self._expand_button.setCursor(Qt.CursorShape.PointingHandCursor)
self._expand_button.clicked.connect(self._toggle_expanded)
text_layout = QVBoxLayout()
text_layout.setContentsMargins(0, 0, 0, 0)
@@ -115,18 +129,128 @@ class BeamlineStatePill(BECWidget, QWidget):
text_layout.addWidget(self._name_label)
text_layout.addWidget(self._detail_label)
layout = QHBoxLayout(self)
layout.setContentsMargins(10, 8, 12, 8)
layout.setSpacing(10)
layout.addWidget(self._stripe)
layout.addWidget(self._icon_label)
layout.addLayout(text_layout, 1)
layout.addWidget(self._status_label, 0, Qt.AlignmentFlag.AlignRight)
header_layout = QHBoxLayout(self._header)
header_layout.setContentsMargins(10, 8, 12, 8)
header_layout.setSpacing(10)
header_layout.addWidget(self._stripe)
header_layout.addWidget(self._icon_label)
header_layout.addLayout(text_layout, 1)
header_layout.addWidget(self._status_label, 0, Qt.AlignmentFlag.AlignRight)
header_layout.addWidget(self._expand_button)
self._settings = QWidget(self)
self._settings.setObjectName("beamline_state_settings")
self._settings.setVisible(False)
self._state_type_value = QLabel(self._settings)
self._name_value = QLabel(self._settings)
self._title_edit = QLineEdit(self._settings)
self._device_edit = DeviceComboBox(parent=self._settings, client=client)
self._signal_edit = SignalComboBox(
parent=self._settings, client=client, require_device=True
)
self._low_limit_enabled, self._low_limit = self._create_optional_limit_row()
self._high_limit_enabled, self._high_limit = self._create_optional_limit_row()
self._tolerance = BECSpinBox(self._settings)
self._configure_settings_spinbox(self._tolerance)
self._device_edit.device_selected.connect(self._on_settings_device_selected)
self._device_edit.device_reset.connect(self._on_settings_device_reset)
self._low_limit_enabled.toggled.connect(self._low_limit.setEnabled)
self._high_limit_enabled.toggled.connect(self._high_limit.setEnabled)
self._type_label = self._create_settings_label("Type")
self._name_settings_label = self._create_settings_label("Name")
self._title_label = self._create_settings_label("Title")
self._device_label = self._create_settings_label("Device")
self._signal_label = self._create_settings_label("Signal")
self._low_limit_label = self._create_settings_label("Low limit")
self._high_limit_label = self._create_settings_label("High limit")
self._tolerance_label = self._create_settings_label("Tolerance")
button_layout = QHBoxLayout()
button_layout.setContentsMargins(0, 0, 0, 0)
button_layout.setSpacing(8)
button_layout.addStretch(1)
self._update_button = QPushButton("Update", self._settings)
self._update_button.setIcon(material_icon("save", convert_to_pixmap=False))
self._remove_button = QPushButton("Remove", self._settings)
self._remove_button.setObjectName("beamline_state_remove_button")
self._remove_button.setIcon(material_icon("delete", convert_to_pixmap=False))
self._update_button.clicked.connect(self._emit_update_requested)
self._remove_button.clicked.connect(self._emit_remove_requested)
button_layout.addWidget(self._update_button)
button_layout.addWidget(self._remove_button)
self._settings_grid = QGridLayout()
self._settings_grid.setContentsMargins(12, 8, 12, 8)
self._settings_grid.setHorizontalSpacing(10)
self._settings_grid.setVerticalSpacing(8)
self._settings_grid.addWidget(self._type_label, 0, 0)
self._settings_grid.addWidget(self._state_type_value, 0, 1)
self._settings_grid.addWidget(self._name_settings_label, 0, 2)
self._settings_grid.addWidget(self._name_value, 0, 3)
self._settings_grid.addWidget(self._title_label, 1, 0)
self._settings_grid.addWidget(self._title_edit, 1, 1, 1, 3)
self._settings_grid.addWidget(self._device_label, 2, 0)
self._settings_grid.addWidget(self._device_edit, 2, 1)
self._settings_grid.addWidget(self._signal_label, 2, 2)
self._settings_grid.addWidget(self._signal_edit, 2, 3)
self._settings_grid.addWidget(self._low_limit_label, 3, 0)
self._settings_grid.addWidget(self._low_limit.parentWidget(), 3, 1)
self._settings_grid.addWidget(self._high_limit_label, 3, 2)
self._settings_grid.addWidget(self._high_limit.parentWidget(), 3, 3)
self._settings_grid.addWidget(self._tolerance_label, 4, 0)
self._settings_grid.addWidget(self._tolerance, 4, 1)
self._settings_grid.addLayout(button_layout, 4, 2, 1, 2)
self._settings_grid.setColumnStretch(1, 1)
self._settings_grid.setColumnStretch(3, 1)
self._limit_widgets = (
self._low_limit_label,
self._low_limit.parentWidget(),
self._high_limit_label,
self._high_limit.parentWidget(),
self._tolerance_label,
self._tolerance,
)
settings_layout = QVBoxLayout(self._settings)
settings_layout.setContentsMargins(0, 0, 0, 0)
settings_layout.setSpacing(0)
settings_layout.addLayout(self._settings_grid)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(self._header)
layout.addWidget(self._settings)
self.setLayout(layout)
for widget in (
self._header,
self._stripe,
self._icon_label,
self._name_label,
self._status_label,
self._detail_label,
):
widget.installEventFilter(self)
self.setMinimumHeight(58)
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 {
self._header,
self._stripe,
self._icon_label,
self._name_label,
self._status_label,
self._detail_label,
}:
self._toggle_expanded()
return True
return super().eventFilter(watched, event)
@property
def state_name(self) -> str | None:
"""Name of the BEC beamline state displayed by this pill."""
@@ -162,6 +286,11 @@ class BeamlineStatePill(BECWidget, QWidget):
self.update_state, MessageEndpoints.beamline_state(self._state_name)
)
def set_state_config(self, state_config: dict[str, Any]) -> None:
"""Set the editable BEC state configuration displayed by the expanded panel."""
self._state_config = state_config
self._populate_settings()
def _refresh_latest_state(self) -> None:
if self._state_name is None:
return
@@ -218,6 +347,8 @@ class BeamlineStatePill(BECWidget, QWidget):
self._icon_label.setPixmap(
material_icon(icon_name, size=(20, 20), color=on_accent, filled=True)
)
expand_icon = "expand_less" if self._expanded else "expand_more"
self._expand_button.setIcon(material_icon(expand_icon, convert_to_pixmap=False))
self._status_label.setText(self._STATUS_LABELS[self._status])
self._detail_label.setText(self._label)
self.setToolTip(self._label)
@@ -248,8 +379,175 @@ class BeamlineStatePill(BECWidget, QWidget):
f"color: {colors['muted']};"
"font-size: 11px;"
"}"
"QWidget#beamline_state_settings {"
f"border-top: 1px solid {colors['border']};"
"}"
"QPushButton#beamline_state_remove_button {"
"background-color: #cc181e;"
"border: 1px solid #cc181e;"
"color: white;"
"border-radius: 4px;"
"padding: 4px 10px;"
"}"
"QPushButton#beamline_state_remove_button:hover {"
"background-color: #a91419;"
"border-color: #a91419;"
"}"
)
def _toggle_expanded(self) -> None:
self._expanded = not self._expanded
self._settings.setVisible(self._expanded)
self._apply_visual_state()
def _populate_settings(self) -> None:
state_type = self._state_type()
self._state_type_value.setText(state_type or "-")
self._name_value.setText(self._state_name or "-")
self._title_edit.setText(str(self._state_field("title") or ""))
device = str(self._state_field("device") or "")
signal = str(self._state_field("signal") or "")
self._set_settings_device(device)
self._set_settings_signal(signal)
show_limits = state_type == "DeviceWithinLimitsState" or any(
self._state_field(key) is not None for key in ("low_limit", "high_limit", "tolerance")
)
for widget in self._limit_widgets:
widget.setVisible(show_limits)
if not show_limits:
return
self._set_optional_limit(
self._low_limit_enabled, self._low_limit, self._state_field("low_limit")
)
self._set_optional_limit(
self._high_limit_enabled, self._high_limit, self._state_field("high_limit")
)
tolerance = self._state_field("tolerance")
self._tolerance.setValue(float(tolerance) if tolerance is not None else 0.1)
def edited_parameters(self) -> dict[str, Any]:
"""Return editable parameters from the expanded settings panel."""
device = self._device_edit.currentText().strip()
signal = self._optional_signal()
if not device:
raise ValueError("Device is required.")
params: dict[str, Any] = {
"title": self._optional_text(self._title_edit),
"device": device,
"signal": signal,
}
if self._state_type() == "DeviceWithinLimitsState":
params.update(
{
"low_limit": (
self._low_limit.value() if self._low_limit_enabled.isChecked() else None
),
"high_limit": (
self._high_limit.value() if self._high_limit_enabled.isChecked() else None
),
"tolerance": self._tolerance.value(),
}
)
return params
def _emit_update_requested(self) -> None:
if self._state_name is None:
return
try:
parameters = self.edited_parameters()
except ValueError as exc:
QMessageBox.warning(self, "Invalid Beamline State", str(exc))
return
self.update_requested.emit(self._state_name, parameters)
def _emit_remove_requested(self) -> None:
if self._state_name is None:
return
self.remove_requested.emit(self._state_name)
def _state_field(self, name: str) -> Any:
parameters = self._state_config.get("parameters")
if isinstance(parameters, dict) and name in parameters:
return parameters.get(name)
return self._state_config.get(name)
def _state_type(self) -> str:
return str(self._state_config.get("state_type") or self._state_field("state_type") or "")
def _on_settings_device_selected(self, device: str) -> None:
self._signal_edit.set_device(device)
def _on_settings_device_reset(self) -> None:
self._signal_edit.set_device(None)
def _set_settings_device(self, device: str) -> None:
if not device:
self._device_edit.setCurrentText("")
self._signal_edit.set_device(None)
return
self._device_edit.set_device(device)
if self._device_edit.currentText() != device:
self._device_edit.setCurrentText(device)
if self._device_edit.is_valid_input:
self._signal_edit.set_device(device)
def _set_settings_signal(self, signal: str) -> None:
if not signal:
self._signal_edit.setCurrentText("")
return
self._signal_edit.set_signal(signal)
if (
self._signal_edit.currentText() != signal
and self._signal_edit.get_signal_name() != signal
):
self._signal_edit.setCurrentText(signal)
@staticmethod
def _optional_text(line_edit: QLineEdit) -> str | None:
value = line_edit.text().strip()
return value or None
def _optional_signal(self) -> str | None:
value = self._signal_edit.get_signal_name().strip()
return value or None
@staticmethod
def _configure_settings_spinbox(spin_box: BECSpinBox) -> None:
spin_box.setRange(-1_000_000_000, 1_000_000_000)
spin_box.setDecimals(6)
spin_box.setFixedWidth(140)
def _create_optional_limit_row(self) -> tuple[QCheckBox, BECSpinBox]:
container = QWidget(self)
checkbox = QCheckBox("Enabled", container)
spin_box = BECSpinBox(container)
self._configure_settings_spinbox(spin_box)
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
layout.addWidget(checkbox)
layout.addWidget(spin_box)
layout.addStretch(1)
return checkbox, spin_box
def _create_settings_label(self, text: str) -> QLabel:
label = QLabel(text, self._settings)
label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
label.setTextFormat(Qt.TextFormat.PlainText)
return label
@staticmethod
def _set_optional_limit(checkbox: QCheckBox, spin_box: BECSpinBox, value: Any) -> None:
enabled = value is not None
checkbox.setChecked(enabled)
spin_box.setEnabled(enabled)
if enabled:
spin_box.setValue(float(value))
def _clear_state_flash(self) -> None:
self._flash_active = False
self._apply_visual_state()
@@ -413,8 +711,14 @@ class AddBeamlineStateDialog(QDialog):
if self._cleaned_up:
return
self._cleaned_up = True
self._device.device_selected.disconnect(self._on_valid_device_selected)
try:
self._device.device_selected.disconnect(self._on_valid_device_selected)
except RuntimeError:
pass
try:
self._device.device_reset.disconnect(self._on_device_reset)
except RuntimeError:
pass
self._device.close()
self._device.deleteLater()
self._signal.close()
@@ -818,16 +1122,20 @@ class BeamlineStateManager(BECWidget, QWidget):
title = state.get("title") or name
if name in self._state_pills:
self._state_pills[name].set_state_name(name, title=title)
self._state_pills[name].set_state_config(state)
continue
self._add_pill(name, title=title)
self._add_pill(name, title=title, state_config=state)
self._apply_filters()
def _add_pill(self, name: str, title: str) -> None:
def _add_pill(self, name: str, title: str, state_config: dict[str, Any]) -> None:
pill = BeamlineStatePill(
parent=self._content, state_name=name, title=title, client=self.client
)
pill.set_state_config(state_config)
pill.state_changed.connect(self._on_pill_state_changed)
pill.update_requested.connect(self._update_state_parameters)
pill.remove_requested.connect(self._remove_state_requested)
self._state_pills[name] = pill
def _remove_pill(self, name: str) -> None:
@@ -836,6 +1144,14 @@ class BeamlineStateManager(BECWidget, QWidget):
pill.state_changed.disconnect(self._on_pill_state_changed)
except RuntimeError:
pass
try:
pill.update_requested.disconnect(self._update_state_parameters)
except RuntimeError:
pass
try:
pill.remove_requested.disconnect(self._remove_state_requested)
except RuntimeError:
pass
pill.cleanup()
self._content_layout.removeWidget(pill)
self._hidden_content_layout.removeWidget(pill)
@@ -893,6 +1209,43 @@ class BeamlineStateManager(BECWidget, QWidget):
if self._selected_statuses is not None:
self._apply_filters()
@Slot(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
if state_client is None or not hasattr(state_client, "update_parameters"):
QMessageBox.warning(
self, "Cannot Update State", f"Beamline state '{state_name}' is not available."
)
return
try:
state_client.update_parameters(**parameters)
except Exception as exc:
QMessageBox.warning(self, "Cannot Update State", str(exc))
@Slot(str)
def _remove_state_requested(self, state_name: str) -> None:
reply = QMessageBox.question(
self,
"Remove Beamline State",
f"Remove beamline state '{state_name}'?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
beamline_states = getattr(self.client, "beamline_states", None)
if beamline_states is None or not hasattr(beamline_states, "delete"):
QMessageBox.warning(
self, "Cannot Remove State", "BEC client has no beamline state manager."
)
return
try:
beamline_states.delete(state_name)
except Exception as exc:
QMessageBox.warning(self, "Cannot Remove State", str(exc))
def _toggle_hidden_states(self, checked: bool) -> None:
self._hidden_expanded = checked
self._apply_filters()
@@ -934,7 +1287,11 @@ class BeamlineStateManager(BECWidget, QWidget):
if isinstance(state, dict):
return state
if hasattr(state, "model_dump"):
return state.model_dump()
state_dict = state.model_dump()
state_type = getattr(state, "state_type", None)
if state_type is not None:
state_dict.setdefault("state_type", state_type)
return state_dict
return {"name": getattr(state, "name", None), "title": getattr(state, "title", None)}
def cleanup(self) -> None:
+91 -1
View File
@@ -1,6 +1,7 @@
import shiboken6
from bec_lib import messages
from qtpy.QtCore import QCoreApplication, QEvent
from qtpy.QtCore import QCoreApplication, QEvent, Qt
from qtpy.QtWidgets import QMessageBox
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.widgets.services.beamline_states.beamline_state_pill import (
@@ -8,6 +9,8 @@ from bec_widgets.widgets.services.beamline_states.beamline_state_pill import (
BeamlineStateManager,
BeamlineStatePill,
)
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
from bec_widgets.widgets.utility.spinbox.decimal_spinbox import BECSpinBox
from .client_mocks import mocked_client
@@ -42,6 +45,48 @@ def test_beamline_state_pill_ignores_other_states(qtbot, mocked_client):
assert widget.toolTip() == "No state information available."
def test_beamline_state_pill_expands_and_emits_updated_limits(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": {
"name": "limits",
"title": "Limits",
"device": "samx",
"signal": "samx",
"low_limit": 0.0,
"high_limit": 10.0,
"tolerance": 0.1,
},
}
)
assert widget._settings.isHidden()
qtbot.mouseClick(widget._header, Qt.MouseButton.LeftButton)
widget._high_limit.setValue(20.0)
assert not widget._settings.isHidden()
assert isinstance(widget._device_edit, DeviceComboBox)
assert isinstance(widget._signal_edit, SignalComboBox)
assert widget._device_edit.currentText() == "samx"
assert widget.edited_parameters()["high_limit"] == 20.0
with qtbot.waitSignal(widget.update_requested) as signal:
widget._update_button.click()
assert signal.args[0] == "limits"
assert signal.args[1]["device"] == "samx"
assert signal.args[1]["signal"] == "samx"
assert signal.args[1]["low_limit"] == 0.0
assert signal.args[1]["high_limit"] == 20.0
assert signal.args[1]["tolerance"] == 0.1
def test_beamline_state_manager_adds_and_removes_pills(qtbot, mocked_client):
widget = BeamlineStateManager(client=mocked_client)
qtbot.addWidget(widget)
@@ -194,6 +239,51 @@ def test_beamline_state_manager_filters_devices(qtbot, mocked_client):
assert widget._available_devices() == ["samx", "samy"]
def test_beamline_state_manager_updates_state_parameters(qtbot, mocked_client):
widget = BeamlineStateManager(client=mocked_client)
qtbot.addWidget(widget)
class StateClient:
def __init__(self):
self.parameters = None
def update_parameters(self, **kwargs):
self.parameters = kwargs
class StateManager:
def __init__(self):
self.limits = StateClient()
mocked_client.beamline_states = StateManager()
widget._update_state_parameters("limits", {"low_limit": -1.0, "high_limit": 20.0})
assert mocked_client.beamline_states.limits.parameters == {
"low_limit": -1.0,
"high_limit": 20.0,
}
def test_beamline_state_manager_removes_state(qtbot, mocked_client, monkeypatch):
widget = BeamlineStateManager(client=mocked_client)
qtbot.addWidget(widget)
class StateManager:
def __init__(self):
self.deleted = None
def delete(self, state_name):
self.deleted = state_name
mocked_client.beamline_states = StateManager()
monkeypatch.setattr(
QMessageBox, "question", lambda *args, **kwargs: QMessageBox.StandardButton.Yes
)
widget._remove_state_requested("limits")
assert mocked_client.beamline_states.deleted == "limits"
def test_add_beamline_state_dialog_uses_device_signal_widgets_and_normalizes_name(
qtbot, mocked_client
):