feat(beamline-states): collapse all functionality with cleanup of not used settings widgets if state is not dirty

This commit is contained in:
2026-06-12 15:52:32 +02:00
committed by Jan Wyzula
co-authored by Jan Wyzula
parent 9550866b67
commit 768c138576
4 changed files with 131 additions and 16 deletions
+6
View File
@@ -729,6 +729,12 @@ class BeamlineStateManager(RPCBase):
None
"""
@rpc_call
def collapse_all(self) -> "None":
"""
Collapse the settings panel of all displayed state pills.
"""
@rpc_call
def state_summary(self) -> "dict[str, dict[str, str]]":
"""
@@ -208,7 +208,15 @@ class BeamlineStateManager(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "format_list_bulleted"
USER_ACCESS = ["clear_filters", "state_summary", "remove", "attach", "detach", "screenshot"]
USER_ACCESS = [
"clear_filters",
"collapse_all",
"state_summary",
"remove",
"attach",
"detach",
"screenshot",
]
def __init__(
self,
@@ -294,22 +302,28 @@ class BeamlineStateManager(BECWidget, QWidget):
clear_filters = MaterialIconAction(
"filter_alt_off", "Clear beamline state filters", filled=True, parent=self
)
collapse_all = MaterialIconAction(
"collapse_all", "Collapse all states", filled=True, parent=self
)
add_state.action.triggered.connect(self.open_add_state_dialog)
filter_states.action.triggered.connect(self.open_status_filter_dialog)
filter_devices.action.triggered.connect(self.open_device_filter_dialog)
clear_filters.action.triggered.connect(self.clear_filters)
collapse_all.action.triggered.connect(self.collapse_all)
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("collapse_all", collapse_all)
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("collapse_all")
toolbar.add_bundle(bundle)
toolbar.show_bundles(["beamline_state_manager"])
return toolbar
@@ -382,13 +396,20 @@ class BeamlineStateManager(BECWidget, QWidget):
self._hidden_expanded = False
self._apply_filters()
@SafeSlot()
def collapse_all(self) -> None:
"""Collapse the settings panel of all displayed state pills."""
for pill in self._state_pills.values():
pill.set_expanded(False)
def state_summary(self) -> dict[str, dict[str, str]]:
"""
Return the displayed beamline states with their current status and label.
Return all beamline states (including filtered ones) with their current status and label.
Returns:
dict: Mapping of state name to a dictionary with ``status`` and ``label`` keys.
"""
return {
name: {"status": pill._status, "label": pill._label}
for name, pill in self._state_pills.items()
@@ -410,7 +410,14 @@ class BeamlineStatePill(BECWidget, QWidget):
return self._expanded
def set_expanded(self, expanded: bool) -> None:
"""Set the editable settings panel expanded state."""
"""
Set the editable settings panel expanded state.
The settings form is built on demand when the panel expands and released again on
collapse, so collapsed pills do not keep live device/signal widgets and their BEC
subscriptions around. Unsaved edits are discarded on collapse.
"""
expanded = bool(expanded)
if expanded == self._expanded:
return
@@ -418,6 +425,8 @@ class BeamlineStatePill(BECWidget, QWidget):
self._ensure_settings_form_current()
self._expanded = expanded
self._settings.setVisible(expanded)
if not expanded:
self._release_config_form()
self._apply_visual_state()
self.row_height_changed.emit()
@@ -438,6 +447,18 @@ class BeamlineStatePill(BECWidget, QWidget):
self.mark_current_settings_clean()
return self._ensure_config_form()
def _release_config_form(self) -> None:
if self._config_form is None:
return
self._config_form_host.removeWidget(self._config_form)
self._config_form.cleanup()
self._config_form.setParent(None)
self._config_form.deleteLater()
self._config_form = None
self._settings_baseline = {}
self._settings_form_stale = True
self._update_settings_dirty_state()
def _populate_settings(self) -> None:
self._populating_settings = True
try:
+80 -13
View File
@@ -21,16 +21,34 @@ def _state(name: str, state_type: str, parameters: dict | None = None):
)
def _limits_state(name: str = "limits", **overrides):
parameters = {
def _wire_state(
state_class: type[bl_states.BeamlineState], config: bl_states.BeamlineStateConfig
) -> messages.BeamlineStateConfig:
return messages.BeamlineStateConfig(
name=config.name,
state_type=state_class.__name__,
parameters=config.model_dump(exclude={"name"}),
)
def _limits_state(name: str = "limits", **overrides) -> messages.BeamlineStateConfig:
values = {
"device": "samx",
"signal": "samx",
"low_limit": 0.0,
"high_limit": 10.0,
"tolerance": 0.1,
}
parameters.update(overrides)
return _state(name, "DeviceWithinLimitsState", parameters)
values.update(overrides)
config = bl_states.DeviceWithinLimitsState.CONFIG_CLASS(name=name, **values)
return _wire_state(bl_states.DeviceWithinLimitsState, config)
def _shutter_state(
name: str = "shutter_open", device: str = "samy"
) -> messages.BeamlineStateConfig:
config = bl_states.ShutterState.CONFIG_CLASS(name=name, device=device)
return _wire_state(bl_states.ShutterState, config)
def test_beamline_state_pill_updates_from_message(qtbot, mocked_client):
@@ -132,6 +150,44 @@ def test_beamline_state_pill_reverts_changed_settings(qtbot, mocked_client):
)
def test_beamline_state_pill_releases_form_on_collapse(qtbot, mocked_client):
limits_pill = create_widget(qtbot, BeamlineStatePill, state_name="limits", client=mocked_client)
limits_pill.set_state_config(_limits_state())
limits_pill.set_expanded(True)
device_widget = limits_pill._config_form.input_widget("device")
limits_pill.set_expanded(False)
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
assert limits_pill._config_form is None
assert not shiboken6.isValid(device_widget)
limits_pill.set_expanded(True)
assert limits_pill._config_form is not None
assert limits_pill._config_form.input_widget("high_limit").value() == 10.0
assert not limits_pill._update_button.isEnabled()
def test_beamline_state_pill_collapse_discards_unsaved_edits(qtbot, mocked_client):
limits_pill = create_widget(qtbot, BeamlineStatePill, state_name="limits", client=mocked_client)
limits_pill.set_state_config(_limits_state())
limits_pill.set_expanded(True)
limits_pill._config_form.input_widget("high_limit").setValue(20.0)
assert limits_pill._update_button.isEnabled()
limits_pill.set_expanded(False)
assert limits_pill._config_form is None
limits_pill.set_expanded(True)
assert limits_pill._config_form.input_widget("high_limit").value() == 10.0
assert not limits_pill._update_button.isEnabled()
def test_beamline_state_pill_does_not_override_themed_input_controls(qtbot, mocked_client):
limits_pill = create_widget(qtbot, BeamlineStatePill, state_name="limits", client=mocked_client)
limits_pill.set_state_config(_limits_state())
@@ -251,13 +307,7 @@ def test_beamline_state_manager_propagates_idle_card_background(qtbot, mocked_cl
def test_beamline_state_manager_filters_status(qtbot, mocked_client):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
beamline_state_manager.update_available_states(
{
"states": [
_state("shutter_open", "ShutterState", {"device": "samy"}),
_state("limits", "DeviceWithinLimitsState", {"device": "samx"}),
]
},
{},
{"states": [_shutter_state(), _limits_state()]}, {}
)
assert isinstance(beamline_state_manager._toolbar, ModularToolBar)
@@ -324,8 +374,8 @@ def test_beamline_state_manager_filters_devices(qtbot, mocked_client, monkeypatc
beamline_state_manager.update_available_states(
{
"states": [
_state("samx_limits", "DeviceWithinLimitsState", {"device": "samx"}),
_state("samy_limits", "DeviceWithinLimitsState", {"device": "samy"}),
_limits_state(name="samx_limits"),
_limits_state(name="samy_limits", device="samy"),
]
},
{},
@@ -358,6 +408,23 @@ def test_beamline_state_manager_filters_devices(qtbot, mocked_client, monkeypatc
assert captured["parent"] is beamline_state_manager
def test_beamline_state_manager_collapse_all(qtbot, mocked_client):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
beamline_state_manager.update_available_states(
{"states": [_limits_state(), _state("shutter_open", "ShutterState", {"device": "samy"})]},
{},
)
for pill in beamline_state_manager._state_pills.values():
pill.set_expanded(True)
assert all(pill.is_expanded() for pill in beamline_state_manager._state_pills.values())
collapse_action = beamline_state_manager._toolbar.components.get_action("collapse_all")
collapse_action.action.trigger()
assert not any(pill.is_expanded() for pill in beamline_state_manager._state_pills.values())
def test_beamline_state_manager_backend_echo_repopulates_expanded_pill(qtbot, mocked_client):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
beamline_state_manager.update_available_states({"states": [_limits_state()]}, {})