mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-06-05 12:58:40 +02:00
Wip BL states adding dialog
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
from bec_widgets.widgets.services.beamline_states.beamline_state_pill import (
|
||||
BeamlineStateList,
|
||||
BeamlineStateManager,
|
||||
BeamlineStatePill,
|
||||
)
|
||||
|
||||
__all__ = ["BeamlineStateList", "BeamlineStatePill"]
|
||||
__all__ = ["BeamlineStateManager", "BeamlineStatePill"]
|
||||
|
||||
@@ -1,24 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
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.QtGui import QColor, QPalette
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
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
|
||||
|
||||
|
||||
class BeamlineStatePill(BECWidget, QWidget):
|
||||
@@ -291,17 +309,295 @@ class BeamlineStatePill(BECWidget, QWidget):
|
||||
super().cleanup()
|
||||
|
||||
|
||||
class BeamlineStateList(BECWidget, QWidget):
|
||||
"""
|
||||
Widget displaying all BEC beamline states as a vertical list of pills.
|
||||
class AddBeamlineStateDialog(QDialog):
|
||||
"""Dialog for creating supported beamline state configurations."""
|
||||
|
||||
The list subscribes to ``MessageEndpoints.available_beamline_states()`` and creates, updates,
|
||||
or removes child ``BeamlineStatePill`` widgets as the set of configured states changes.
|
||||
def __init__(self, parent: QWidget | None = None, client=None) -> None:
|
||||
super().__init__(parent=parent)
|
||||
self.setWindowTitle("Add Beamline State")
|
||||
self._cleaned_up = False
|
||||
self._config: (
|
||||
bl_states.DeviceStateConfig | bl_states.DeviceWithinLimitsStateConfig | None
|
||||
) = None
|
||||
self._auto_generated_name: str | None = None
|
||||
|
||||
self._type_combo = QComboBox(self)
|
||||
self._type_combo.addItem("Device within limits", "device_within_limits")
|
||||
self._type_combo.addItem("Shutter", "shutter")
|
||||
self._type_combo.currentIndexChanged.connect(self._update_field_visibility)
|
||||
|
||||
self._name = QLineEdit(self)
|
||||
self._name.setPlaceholderText("samx_limits")
|
||||
self._title = QLineEdit(self)
|
||||
self._device = DeviceComboBox(parent=self, client=client)
|
||||
self._signal = SignalComboBox(parent=self, client=client, require_device=True)
|
||||
self._low_limit = QDoubleSpinBox(self)
|
||||
self._high_limit = QDoubleSpinBox(self)
|
||||
self._tolerance = QDoubleSpinBox(self)
|
||||
self._device.device_selected.connect(self._on_valid_device_selected)
|
||||
self._device.device_reset.connect(lambda: self._signal.set_device(None))
|
||||
|
||||
for spin_box in (self._low_limit, self._high_limit):
|
||||
spin_box.setRange(-1_000_000_000, 1_000_000_000)
|
||||
spin_box.setDecimals(6)
|
||||
self._low_limit.setValue(0.0)
|
||||
self._high_limit.setValue(10.0)
|
||||
self._tolerance.setRange(0.0, 1_000_000_000)
|
||||
self._tolerance.setDecimals(6)
|
||||
self._tolerance.setValue(0.1)
|
||||
|
||||
self._form = QFormLayout()
|
||||
self._form.addRow("State type", self._type_combo)
|
||||
self._form.addRow("Name", self._name)
|
||||
self._form.addRow("Title", self._title)
|
||||
self._form.addRow("Device", self._device)
|
||||
self._form.addRow("Signal", self._signal)
|
||||
self._form.addRow("Low limit", self._low_limit)
|
||||
self._form.addRow("High limit", self._high_limit)
|
||||
self._form.addRow("Tolerance", self._tolerance)
|
||||
|
||||
self._buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self
|
||||
)
|
||||
self._buttons.accepted.connect(self.accept)
|
||||
self._buttons.rejected.connect(self.reject)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addLayout(self._form)
|
||||
layout.addWidget(self._buttons)
|
||||
self.setLayout(layout)
|
||||
self._update_field_visibility()
|
||||
|
||||
def config(self) -> bl_states.DeviceStateConfig | bl_states.DeviceWithinLimitsStateConfig:
|
||||
state_type = self._type_combo.currentData()
|
||||
name = self._state_name()
|
||||
title = self._optional_text(self._title)
|
||||
device = self._selected_device()
|
||||
signal = self._selected_signal()
|
||||
|
||||
if state_type == "shutter":
|
||||
return bl_states.DeviceStateConfig(name=name, title=title, device=device, signal=signal)
|
||||
|
||||
return bl_states.DeviceWithinLimitsStateConfig(
|
||||
name=name,
|
||||
title=title,
|
||||
device=device,
|
||||
signal=signal,
|
||||
low_limit=self._low_limit.value(),
|
||||
high_limit=self._high_limit.value(),
|
||||
tolerance=self._tolerance.value(),
|
||||
)
|
||||
|
||||
def accept(self) -> None:
|
||||
try:
|
||||
self._config = self.config()
|
||||
except Exception as exc:
|
||||
QMessageBox.warning(self, "Invalid Beamline State", str(exc))
|
||||
return
|
||||
super().accept()
|
||||
|
||||
@property
|
||||
def config_result(
|
||||
self,
|
||||
) -> bl_states.DeviceStateConfig | bl_states.DeviceWithinLimitsStateConfig:
|
||||
if self._config is None:
|
||||
raise RuntimeError("Beamline state dialog was not accepted with a valid config.")
|
||||
return self._config
|
||||
|
||||
def cleanup(self) -> None:
|
||||
if self._cleaned_up:
|
||||
return
|
||||
self._cleaned_up = True
|
||||
self._device.cleanup()
|
||||
self._signal.cleanup()
|
||||
|
||||
def done(self, result: int) -> None:
|
||||
try:
|
||||
self.cleanup()
|
||||
finally:
|
||||
super().done(result)
|
||||
|
||||
def _on_valid_device_selected(self, device: str) -> None:
|
||||
self._signal.set_device(device)
|
||||
current_name = self._name.text().strip()
|
||||
if current_name and current_name != self._auto_generated_name:
|
||||
return
|
||||
generated_name = f"{self._normalize_identifier(device)}_{self._state_name_suffix()}"
|
||||
self._auto_generated_name = generated_name
|
||||
self._name.setText(generated_name)
|
||||
|
||||
def _update_field_visibility(self) -> 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)
|
||||
label = self._form.labelForField(widget)
|
||||
if label is not None:
|
||||
label.setVisible(show_limits)
|
||||
|
||||
def _state_name(self) -> str:
|
||||
raw_name = self._name.text().strip()
|
||||
if not raw_name:
|
||||
raise ValueError("Name is required.")
|
||||
name = self._normalize_identifier(raw_name)
|
||||
self._name.setText(name)
|
||||
return name
|
||||
|
||||
def _selected_device(self) -> str:
|
||||
device = self._device.currentText().strip()
|
||||
if not device:
|
||||
raise ValueError("Device is required.")
|
||||
if not self._device.is_valid_input:
|
||||
raise ValueError(f"Device '{device}' is not available.")
|
||||
return device
|
||||
|
||||
def _selected_signal(self) -> str | None:
|
||||
signal = self._signal.get_signal_name().strip()
|
||||
if not signal:
|
||||
return None
|
||||
if not self._signal.is_valid_input:
|
||||
raise ValueError(
|
||||
f"Signal '{signal}' is not available for device '{self._device.currentText()}'."
|
||||
)
|
||||
return signal
|
||||
|
||||
@staticmethod
|
||||
def _optional_text(line_edit: QLineEdit) -> str | None:
|
||||
value = line_edit.text().strip()
|
||||
return value or None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_identifier(value: str) -> str:
|
||||
name = re.sub(r"\W+", "_", value.strip())
|
||||
name = re.sub(r"_+", "_", name).strip("_")
|
||||
if not name:
|
||||
raise ValueError("Name must contain at least one letter, number, or underscore.")
|
||||
if name[0].isdigit():
|
||||
name = f"state_{name}"
|
||||
return name
|
||||
|
||||
def _state_name_suffix(self) -> str:
|
||||
if self._type_combo.currentData() == "device_within_limits":
|
||||
return "limits"
|
||||
return "state"
|
||||
|
||||
|
||||
class StateFilterDialog(QDialog):
|
||||
"""Dialog for selecting visible beamline states."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
state_configs: dict[str, dict[str, Any]],
|
||||
selected_state_names: set[str] | None,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent=parent)
|
||||
self.setWindowTitle("Filter Beamline States")
|
||||
self._checkboxes: dict[str, QCheckBox] = {}
|
||||
|
||||
controls = QHBoxLayout()
|
||||
select_all = QPushButton("Select all", self)
|
||||
clear = QPushButton("Clear", self)
|
||||
select_all.clicked.connect(lambda: self._set_all(True))
|
||||
clear.clicked.connect(lambda: self._set_all(False))
|
||||
controls.addWidget(select_all)
|
||||
controls.addWidget(clear)
|
||||
controls.addStretch(1)
|
||||
|
||||
list_layout = QVBoxLayout()
|
||||
for name, state in sorted(state_configs.items()):
|
||||
checkbox = QCheckBox(str(state.get("title") or name), self)
|
||||
checkbox.setChecked(selected_state_names is None or name in selected_state_names)
|
||||
checkbox.setToolTip(name)
|
||||
self._checkboxes[name] = checkbox
|
||||
list_layout.addWidget(checkbox)
|
||||
list_layout.addStretch(1)
|
||||
|
||||
box = QGroupBox("Displayed states", self)
|
||||
box.setLayout(list_layout)
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self
|
||||
)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addLayout(controls)
|
||||
layout.addWidget(box)
|
||||
layout.addWidget(buttons)
|
||||
self.setLayout(layout)
|
||||
|
||||
def selected_state_names(self) -> set[str] | None:
|
||||
selected = {name for name, checkbox in self._checkboxes.items() if checkbox.isChecked()}
|
||||
if selected == set(self._checkboxes):
|
||||
return None
|
||||
return selected
|
||||
|
||||
def _set_all(self, checked: bool) -> None:
|
||||
for checkbox in self._checkboxes.values():
|
||||
checkbox.setChecked(checked)
|
||||
|
||||
|
||||
class DeviceFilterDialog(QDialog):
|
||||
"""Dialog for filtering beamline states by configured device."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
devices: list[str],
|
||||
selected_devices: set[str] | None,
|
||||
device_filter_text: str,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent=parent)
|
||||
self.setWindowTitle("Filter Beamline State Devices")
|
||||
self._checkboxes: dict[str, QCheckBox] = {}
|
||||
|
||||
self._device_text = QLineEdit(self)
|
||||
self._device_text.setPlaceholderText("Device name or comma-separated names")
|
||||
self._device_text.setText(device_filter_text)
|
||||
|
||||
list_layout = QVBoxLayout()
|
||||
for device in devices:
|
||||
checkbox = QCheckBox(device, self)
|
||||
checkbox.setChecked(selected_devices is not None and device in selected_devices)
|
||||
self._checkboxes[device] = checkbox
|
||||
list_layout.addWidget(checkbox)
|
||||
list_layout.addStretch(1)
|
||||
|
||||
box = QGroupBox("Known devices", self)
|
||||
box.setLayout(list_layout)
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self
|
||||
)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self._device_text)
|
||||
layout.addWidget(box)
|
||||
layout.addWidget(buttons)
|
||||
self.setLayout(layout)
|
||||
|
||||
def selected_devices(self) -> set[str] | None:
|
||||
selected = {device for device, checkbox in self._checkboxes.items() if checkbox.isChecked()}
|
||||
return selected or None
|
||||
|
||||
def filter_text(self) -> str:
|
||||
return self._device_text.text().strip()
|
||||
|
||||
|
||||
class BeamlineStateManager(BECWidget, QWidget):
|
||||
"""
|
||||
Widget displaying and managing all BEC beamline states.
|
||||
|
||||
The manager subscribes to ``MessageEndpoints.available_beamline_states()`` and creates,
|
||||
updates, or removes child ``BeamlineStatePill`` widgets as the set of configured states changes.
|
||||
"""
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "format_list_bulleted"
|
||||
USER_ACCESS = ["refresh_states", "remove", "attach", "detach", "screenshot"]
|
||||
USER_ACCESS = ["refresh_states", "clear_filters", "remove", "attach", "detach", "screenshot"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -315,15 +611,22 @@ class BeamlineStateList(BECWidget, QWidget):
|
||||
parent=parent, client=client, config=config, gui_id=gui_id, theme_update=True, **kwargs
|
||||
)
|
||||
self._state_pills: dict[str, BeamlineStatePill] = {}
|
||||
self._state_configs: dict[str, dict[str, Any]] = {}
|
||||
self._state_order: list[str] = []
|
||||
self._selected_state_names: set[str] | None = None
|
||||
self._selected_devices: set[str] | None = None
|
||||
self._device_filter_text = ""
|
||||
self._hidden_expanded = False
|
||||
|
||||
self._empty_label = QLabel("No beamline states available.", self)
|
||||
self._empty_label = QLabel("No beamline states available.\n Add new state from toolbar or CLI.", self)
|
||||
self._empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self._toolbar = self._create_toolbar()
|
||||
|
||||
self._content = QWidget(self)
|
||||
self._content_layout = QVBoxLayout(self._content)
|
||||
self._content_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._content_layout.setSpacing(6)
|
||||
self._content_layout.addWidget(self._empty_label)
|
||||
self._content_layout.addStretch(1)
|
||||
|
||||
self._scroll_area = QScrollArea(self)
|
||||
@@ -334,7 +637,21 @@ class BeamlineStateList(BECWidget, QWidget):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(8, 8, 8, 8)
|
||||
layout.setSpacing(6)
|
||||
layout.addWidget(self._toolbar)
|
||||
layout.addWidget(self._empty_label)
|
||||
layout.addWidget(self._scroll_area)
|
||||
self._hidden_summary = QToolButton(self)
|
||||
self._hidden_summary.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
|
||||
self._hidden_summary.setCheckable(True)
|
||||
self._hidden_summary.clicked.connect(self._toggle_hidden_states)
|
||||
layout.addWidget(self._hidden_summary)
|
||||
|
||||
self._hidden_content = QWidget(self)
|
||||
self._hidden_content_layout = QVBoxLayout(self._hidden_content)
|
||||
self._hidden_content_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._hidden_content_layout.setSpacing(6)
|
||||
self._hidden_content_layout.addStretch(1)
|
||||
layout.addWidget(self._hidden_content)
|
||||
self.setLayout(layout)
|
||||
|
||||
self.bec_dispatcher.connect_slot(
|
||||
@@ -343,12 +660,106 @@ class BeamlineStateList(BECWidget, QWidget):
|
||||
from_start=True,
|
||||
)
|
||||
self.refresh_states()
|
||||
self._refresh_hidden_summary()
|
||||
|
||||
def _create_toolbar(self) -> ModularToolBar:
|
||||
toolbar = ModularToolBar(parent=self)
|
||||
|
||||
add_state = MaterialIconAction("add", "Add beamline state", filled=True, parent=self)
|
||||
filter_states = MaterialIconAction(
|
||||
"filter_alt", "Filter displayed states", filled=True, parent=self
|
||||
)
|
||||
filter_devices = MaterialIconAction(
|
||||
"devices", "Filter displayed devices", filled=True, parent=self
|
||||
)
|
||||
clear_filters = MaterialIconAction(
|
||||
"filter_alt_off", "Clear beamline state filters", filled=True, parent=self
|
||||
)
|
||||
refresh = MaterialIconAction(
|
||||
"restart_alt", "Refresh beamline states", filled=True, parent=self
|
||||
)
|
||||
|
||||
add_state.action.triggered.connect(self.open_add_state_dialog)
|
||||
filter_states.action.triggered.connect(self.open_state_filter_dialog)
|
||||
filter_devices.action.triggered.connect(self.open_device_filter_dialog)
|
||||
clear_filters.action.triggered.connect(self.clear_filters)
|
||||
refresh.action.triggered.connect(self.refresh_states)
|
||||
|
||||
toolbar.components.add_safe("add_state", add_state)
|
||||
toolbar.components.add_safe("filter_states", filter_states)
|
||||
toolbar.components.add_safe("filter_devices", filter_devices)
|
||||
toolbar.components.add_safe("clear_filters", clear_filters)
|
||||
toolbar.components.add_safe("refresh", refresh)
|
||||
|
||||
bundle = ToolbarBundle("beamline_state_manager", toolbar.components)
|
||||
bundle.add_action("add_state")
|
||||
bundle.add_action("filter_states")
|
||||
bundle.add_action("filter_devices")
|
||||
bundle.add_action("clear_filters")
|
||||
bundle.add_action("refresh")
|
||||
toolbar.add_bundle(bundle)
|
||||
toolbar.show_bundles(["beamline_state_manager"])
|
||||
return toolbar
|
||||
|
||||
@Slot(str)
|
||||
def apply_theme(self, _theme: str) -> None:
|
||||
self.setStyleSheet("BeamlineStateList { border: none; }")
|
||||
colors = BeamlineStatePill._state_colors("unknown")
|
||||
self.setStyleSheet(
|
||||
"BeamlineStateManager { border: none; }"
|
||||
"QToolButton#hidden_states_summary {"
|
||||
f"background-color: {colors['background']};"
|
||||
f"border: 1px solid {colors['border']};"
|
||||
"border-radius: 6px;"
|
||||
"padding: 6px;"
|
||||
"text-align: left;"
|
||||
"}"
|
||||
)
|
||||
for pill in self._state_pills.values():
|
||||
pill.apply_theme(_theme)
|
||||
self._refresh_hidden_summary()
|
||||
|
||||
@Slot()
|
||||
def open_add_state_dialog(self) -> None:
|
||||
dialog = AddBeamlineStateDialog(self, client=self.client)
|
||||
if dialog.exec() != QDialog.Accepted:
|
||||
return
|
||||
beamline_states = getattr(self.client, "beamline_states", None)
|
||||
if beamline_states is None:
|
||||
QMessageBox.warning(
|
||||
self, "Cannot Add State", "BEC client has no beamline state manager."
|
||||
)
|
||||
return
|
||||
try:
|
||||
beamline_states.add(dialog.config_result)
|
||||
except Exception as exc:
|
||||
QMessageBox.warning(self, "Cannot Add State", str(exc))
|
||||
|
||||
@Slot()
|
||||
def open_state_filter_dialog(self) -> None:
|
||||
dialog = StateFilterDialog(self._state_configs, self._selected_state_names, self)
|
||||
if dialog.exec() != QDialog.Accepted:
|
||||
return
|
||||
self._selected_state_names = dialog.selected_state_names()
|
||||
self._apply_filters()
|
||||
|
||||
@Slot()
|
||||
def open_device_filter_dialog(self) -> None:
|
||||
dialog = DeviceFilterDialog(
|
||||
self._available_devices(), self._selected_devices, self._device_filter_text, self
|
||||
)
|
||||
if dialog.exec() != QDialog.Accepted:
|
||||
return
|
||||
self._selected_devices = dialog.selected_devices()
|
||||
self._device_filter_text = dialog.filter_text()
|
||||
self._apply_filters()
|
||||
|
||||
@Slot()
|
||||
def clear_filters(self) -> None:
|
||||
self._selected_state_names = None
|
||||
self._selected_devices = None
|
||||
self._device_filter_text = ""
|
||||
self._hidden_expanded = False
|
||||
self._apply_filters()
|
||||
|
||||
def refresh_states(self) -> None:
|
||||
"""Fetch the latest cached available beamline states and update the list immediately."""
|
||||
@@ -368,7 +779,9 @@ class BeamlineStateList(BECWidget, QWidget):
|
||||
states = content.get("states", [])
|
||||
state_configs = [self._state_config_to_dict(state) for state in states]
|
||||
state_configs = [state for state in state_configs if state.get("name")]
|
||||
state_names = {str(state["name"]) for state in state_configs}
|
||||
self._state_configs = {str(state["name"]): state for state in state_configs}
|
||||
self._state_order = [str(state["name"]) for state in state_configs]
|
||||
state_names = set(self._state_order)
|
||||
|
||||
for removed_name in sorted(set(self._state_pills) - state_names):
|
||||
self._remove_pill(removed_name)
|
||||
@@ -381,22 +794,102 @@ class BeamlineStateList(BECWidget, QWidget):
|
||||
continue
|
||||
self._add_pill(name, title=title)
|
||||
|
||||
self._empty_label.setVisible(not self._state_pills)
|
||||
self._apply_filters()
|
||||
|
||||
def _add_pill(self, name: str, title: str) -> None:
|
||||
pill = BeamlineStatePill(
|
||||
parent=self._content, state_name=name, title=title, client=self.client
|
||||
)
|
||||
self._state_pills[name] = pill
|
||||
self._content_layout.insertWidget(max(self._content_layout.count() - 1, 0), pill)
|
||||
|
||||
def _remove_pill(self, name: str) -> None:
|
||||
pill = self._state_pills.pop(name)
|
||||
pill.cleanup()
|
||||
self._content_layout.removeWidget(pill)
|
||||
self._hidden_content_layout.removeWidget(pill)
|
||||
pill.setParent(None)
|
||||
pill.deleteLater()
|
||||
|
||||
def _apply_filters(self) -> None:
|
||||
visible_names = []
|
||||
hidden_names = []
|
||||
for name in self._state_order:
|
||||
if self._is_state_visible(name):
|
||||
visible_names.append(name)
|
||||
else:
|
||||
hidden_names.append(name)
|
||||
|
||||
self._move_pills_to_layout(visible_names, self._content_layout)
|
||||
self._move_pills_to_layout(hidden_names, self._hidden_content_layout)
|
||||
self._empty_label.setVisible(not visible_names)
|
||||
self._hidden_content.setVisible(self._hidden_expanded and bool(hidden_names))
|
||||
self._refresh_hidden_summary(hidden_count=len(hidden_names))
|
||||
|
||||
def _move_pills_to_layout(self, names: list[str], layout: QVBoxLayout) -> None:
|
||||
for name in names:
|
||||
pill = self._state_pills[name]
|
||||
self._content_layout.removeWidget(pill)
|
||||
self._hidden_content_layout.removeWidget(pill)
|
||||
layout.insertWidget(max(layout.count() - 1, 0), pill)
|
||||
pill.setVisible(True)
|
||||
|
||||
def _is_state_visible(self, name: str) -> bool:
|
||||
if self._selected_state_names is not None and name not in self._selected_state_names:
|
||||
return False
|
||||
|
||||
device = self._state_device(self._state_configs.get(name, {}))
|
||||
if self._selected_devices is not None and device not in self._selected_devices:
|
||||
return False
|
||||
|
||||
tokens = [
|
||||
token.strip().casefold()
|
||||
for token in self._device_filter_text.split(",")
|
||||
if token.strip()
|
||||
]
|
||||
if tokens:
|
||||
if device is None:
|
||||
return False
|
||||
device_lower = device.casefold()
|
||||
if not any(token in device_lower for token in tokens):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _toggle_hidden_states(self, checked: bool) -> None:
|
||||
self._hidden_expanded = checked
|
||||
self._apply_filters()
|
||||
|
||||
def _refresh_hidden_summary(self, hidden_count: int | None = None) -> None:
|
||||
if hidden_count is None:
|
||||
hidden_count = sum(1 for name in self._state_order if not self._is_state_visible(name))
|
||||
self._hidden_summary.setObjectName("hidden_states_summary")
|
||||
self._hidden_summary.setVisible(hidden_count > 0)
|
||||
self._hidden_summary.setChecked(self._hidden_expanded and hidden_count > 0)
|
||||
icon_name = "expand_less" if self._hidden_expanded else "expand_more"
|
||||
self._hidden_summary.setIcon(material_icon(icon_name, convert_to_pixmap=False))
|
||||
suffix = "state is" if hidden_count == 1 else "states are"
|
||||
action = "Hide" if self._hidden_expanded else "Show"
|
||||
self._hidden_summary.setText(
|
||||
f"{hidden_count} {suffix} hidden by filters. {action} hidden states."
|
||||
)
|
||||
self._hidden_content.setVisible(self._hidden_expanded and hidden_count > 0)
|
||||
|
||||
def _available_devices(self) -> list[str]:
|
||||
devices = {
|
||||
device
|
||||
for state in self._state_configs.values()
|
||||
if (device := self._state_device(state)) is not None
|
||||
}
|
||||
return sorted(devices)
|
||||
|
||||
@staticmethod
|
||||
def _state_device(state: dict[str, Any]) -> str | None:
|
||||
parameters = state.get("parameters")
|
||||
if isinstance(parameters, dict):
|
||||
device = parameters.get("device")
|
||||
else:
|
||||
device = state.get("device")
|
||||
return str(device) if device else None
|
||||
|
||||
@staticmethod
|
||||
def _state_config_to_dict(state: Any) -> dict[str, Any]:
|
||||
if isinstance(state, dict):
|
||||
@@ -411,6 +904,7 @@ class BeamlineStateList(BECWidget, QWidget):
|
||||
)
|
||||
for name in list(self._state_pills):
|
||||
self._remove_pill(name)
|
||||
self._toolbar.components.cleanup()
|
||||
super().cleanup()
|
||||
|
||||
|
||||
@@ -432,7 +926,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
theme_row.addStretch(1)
|
||||
theme_row.addWidget(DarkModeButton(parent=window))
|
||||
layout.addLayout(theme_row)
|
||||
layout.addWidget(BeamlineStateList(parent=window))
|
||||
layout.addWidget(BeamlineStateManager(parent=window))
|
||||
|
||||
window.resize(420, 480)
|
||||
window.show()
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from bec_lib import messages
|
||||
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.services.beamline_states.beamline_state_pill import (
|
||||
BeamlineStateList,
|
||||
AddBeamlineStateDialog,
|
||||
BeamlineStateManager,
|
||||
BeamlineStatePill,
|
||||
)
|
||||
|
||||
@@ -37,8 +39,8 @@ def test_beamline_state_pill_ignores_other_states(qtbot, mocked_client):
|
||||
assert widget.toolTip() == "No state information available."
|
||||
|
||||
|
||||
def test_beamline_state_list_adds_and_removes_pills(qtbot, mocked_client):
|
||||
widget = BeamlineStateList(client=mocked_client)
|
||||
def test_beamline_state_manager_adds_and_removes_pills(qtbot, mocked_client):
|
||||
widget = BeamlineStateManager(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
widget.update_available_states(
|
||||
@@ -77,3 +79,113 @@ def test_beamline_state_list_adds_and_removes_pills(qtbot, mocked_client):
|
||||
)
|
||||
|
||||
assert sorted(widget._state_pills) == ["limits"]
|
||||
|
||||
|
||||
def test_beamline_state_manager_filters_states(qtbot, mocked_client):
|
||||
widget = BeamlineStateManager(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
widget.update_available_states(
|
||||
{
|
||||
"states": [
|
||||
{
|
||||
"name": "shutter_open",
|
||||
"title": "Shutter",
|
||||
"state_type": "ShutterState",
|
||||
"parameters": {"device": "samy"},
|
||||
},
|
||||
{
|
||||
"name": "limits",
|
||||
"title": "Limits",
|
||||
"state_type": "DeviceWithinLimitsState",
|
||||
"parameters": {"device": "samx"},
|
||||
},
|
||||
]
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
assert isinstance(widget._toolbar, ModularToolBar)
|
||||
|
||||
widget._selected_state_names = {"limits"}
|
||||
widget._apply_filters()
|
||||
|
||||
assert not widget._hidden_summary.isHidden()
|
||||
assert "1 state is hidden" in widget._hidden_summary.text()
|
||||
assert widget._state_pills["limits"].parent() is widget._content
|
||||
assert widget._state_pills["shutter_open"].parent() is widget._hidden_content
|
||||
|
||||
widget._toggle_hidden_states(True)
|
||||
|
||||
assert not widget._hidden_content.isHidden()
|
||||
|
||||
|
||||
def test_beamline_state_manager_filters_devices(qtbot, mocked_client):
|
||||
widget = BeamlineStateManager(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
widget.update_available_states(
|
||||
{
|
||||
"states": [
|
||||
{
|
||||
"name": "samx_limits",
|
||||
"title": "samx",
|
||||
"state_type": "DeviceWithinLimitsState",
|
||||
"parameters": {"device": "samx"},
|
||||
},
|
||||
{
|
||||
"name": "samy_limits",
|
||||
"title": "samy",
|
||||
"state_type": "DeviceWithinLimitsState",
|
||||
"parameters": {"device": "samy"},
|
||||
},
|
||||
]
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
widget._device_filter_text = "samx"
|
||||
widget._apply_filters()
|
||||
|
||||
assert not widget._hidden_summary.isHidden()
|
||||
assert "1 state is hidden" in widget._hidden_summary.text()
|
||||
assert widget._available_devices() == ["samx", "samy"]
|
||||
|
||||
|
||||
def test_add_beamline_state_dialog_uses_device_signal_widgets_and_normalizes_name(
|
||||
qtbot, mocked_client
|
||||
):
|
||||
dialog = AddBeamlineStateDialog(client=mocked_client)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
dialog._type_combo.setCurrentIndex(1)
|
||||
dialog._name.setText("samx-limits")
|
||||
dialog._title.setText("samx-limits-15")
|
||||
dialog._device.set_device("samx")
|
||||
dialog._signal.set_signal("samx")
|
||||
dialog._high_limit.setValue(15.0)
|
||||
|
||||
config = dialog.config()
|
||||
|
||||
assert config.name == "samx_limits"
|
||||
assert config.title == "samx-limits-15"
|
||||
assert config.device == "samx"
|
||||
assert config.signal == "samx"
|
||||
assert config.low_limit == 0.0
|
||||
assert config.high_limit == 15.0
|
||||
|
||||
|
||||
def test_add_beamline_state_dialog_generates_name_only_after_valid_device_selection(
|
||||
qtbot, mocked_client
|
||||
):
|
||||
dialog = AddBeamlineStateDialog(client=mocked_client)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
dialog._type_combo.setCurrentIndex(1)
|
||||
dialog._device.setCurrentText("s")
|
||||
|
||||
assert dialog._name.text() == ""
|
||||
|
||||
dialog._device.set_device("samx")
|
||||
|
||||
assert dialog._name.text() == "samx_limits"
|
||||
|
||||
Reference in New Issue
Block a user