Skip to content

Commit 6bdf054

Browse files
committed
feat(beamline-states): collapse all functionality with cleanup of not used settings widgets if state is not dirty
1 parent 6577a81 commit 6bdf054

4 files changed

Lines changed: 131 additions & 16 deletions

File tree

bec_widgets/cli/client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,12 @@ def clear_filters(self) -> "None":
729729
None
730730
"""
731731

732+
@rpc_call
733+
def collapse_all(self) -> "None":
734+
"""
735+
Collapse the settings panel of all displayed state pills.
736+
"""
737+
732738
@rpc_call
733739
def state_summary(self) -> "dict[str, dict[str, str]]":
734740
"""

bec_widgets/widgets/services/beamline_states/beamline_state_manager.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,15 @@ class BeamlineStateManager(BECWidget, QWidget):
208208

209209
PLUGIN = True
210210
ICON_NAME = "format_list_bulleted"
211-
USER_ACCESS = ["clear_filters", "state_summary", "remove", "attach", "detach", "screenshot"]
211+
USER_ACCESS = [
212+
"clear_filters",
213+
"collapse_all",
214+
"state_summary",
215+
"remove",
216+
"attach",
217+
"detach",
218+
"screenshot",
219+
]
212220

213221
def __init__(
214222
self,
@@ -294,22 +302,28 @@ def _create_toolbar(self) -> ModularToolBar:
294302
clear_filters = MaterialIconAction(
295303
"filter_alt_off", "Clear beamline state filters", filled=True, parent=self
296304
)
305+
collapse_all = MaterialIconAction(
306+
"collapse_all", "Collapse all states", filled=True, parent=self
307+
)
297308

298309
add_state.action.triggered.connect(self.open_add_state_dialog)
299310
filter_states.action.triggered.connect(self.open_status_filter_dialog)
300311
filter_devices.action.triggered.connect(self.open_device_filter_dialog)
301312
clear_filters.action.triggered.connect(self.clear_filters)
313+
collapse_all.action.triggered.connect(self.collapse_all)
302314

303315
toolbar.components.add_safe("add_state", add_state)
304316
toolbar.components.add_safe("filter_states", filter_states)
305317
toolbar.components.add_safe("filter_devices", filter_devices)
306318
toolbar.components.add_safe("clear_filters", clear_filters)
319+
toolbar.components.add_safe("collapse_all", collapse_all)
307320

308321
bundle = ToolbarBundle("beamline_state_manager", toolbar.components)
309322
bundle.add_action("add_state")
310323
bundle.add_action("filter_states")
311324
bundle.add_action("filter_devices")
312325
bundle.add_action("clear_filters")
326+
bundle.add_action("collapse_all")
313327
toolbar.add_bundle(bundle)
314328
toolbar.show_bundles(["beamline_state_manager"])
315329
return toolbar
@@ -382,13 +396,20 @@ def clear_filters(self) -> None:
382396
self._hidden_expanded = False
383397
self._apply_filters()
384398

399+
@SafeSlot()
400+
def collapse_all(self) -> None:
401+
"""Collapse the settings panel of all displayed state pills."""
402+
for pill in self._state_pills.values():
403+
pill.set_expanded(False)
404+
385405
def state_summary(self) -> dict[str, dict[str, str]]:
386406
"""
387-
Return the displayed beamline states with their current status and label.
407+
Return all beamline states (including filtered ones) with their current status and label.
388408
389409
Returns:
390410
dict: Mapping of state name to a dictionary with ``status`` and ``label`` keys.
391411
"""
412+
392413
return {
393414
name: {"status": pill._status, "label": pill._label}
394415
for name, pill in self._state_pills.items()

bec_widgets/widgets/services/beamline_states/beamline_state_pill.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,14 +410,23 @@ def is_expanded(self) -> bool:
410410
return self._expanded
411411

412412
def set_expanded(self, expanded: bool) -> None:
413-
"""Set the editable settings panel expanded state."""
413+
"""
414+
Set the editable settings panel expanded state.
415+
416+
The settings form is built on demand when the panel expands and released again on
417+
collapse, so collapsed pills do not keep live device/signal widgets and their BEC
418+
subscriptions around. Unsaved edits are discarded on collapse.
419+
"""
420+
414421
expanded = bool(expanded)
415422
if expanded == self._expanded:
416423
return
417424
if expanded:
418425
self._ensure_settings_form_current()
419426
self._expanded = expanded
420427
self._settings.setVisible(expanded)
428+
if not expanded:
429+
self._release_config_form()
421430
self._apply_visual_state()
422431
self.row_height_changed.emit()
423432

@@ -438,6 +447,18 @@ def _ensure_settings_form_current(self) -> PydanticWidgetForm:
438447
self.mark_current_settings_clean()
439448
return self._ensure_config_form()
440449

450+
def _release_config_form(self) -> None:
451+
if self._config_form is None:
452+
return
453+
self._config_form_host.removeWidget(self._config_form)
454+
self._config_form.cleanup()
455+
self._config_form.setParent(None)
456+
self._config_form.deleteLater()
457+
self._config_form = None
458+
self._settings_baseline = {}
459+
self._settings_form_stale = True
460+
self._update_settings_dirty_state()
461+
441462
def _populate_settings(self) -> None:
442463
self._populating_settings = True
443464
try:

tests/unit_tests/test_beamline_state_pill.py

Lines changed: 80 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,34 @@ def _state(name: str, state_type: str, parameters: dict | None = None):
2121
)
2222

2323

24-
def _limits_state(name: str = "limits", **overrides):
25-
parameters = {
24+
def _wire_state(
25+
state_class: type[bl_states.BeamlineState], config: bl_states.BeamlineStateConfig
26+
) -> messages.BeamlineStateConfig:
27+
return messages.BeamlineStateConfig(
28+
name=config.name,
29+
state_type=state_class.__name__,
30+
parameters=config.model_dump(exclude={"name"}),
31+
)
32+
33+
34+
def _limits_state(name: str = "limits", **overrides) -> messages.BeamlineStateConfig:
35+
values = {
2636
"device": "samx",
2737
"signal": "samx",
2838
"low_limit": 0.0,
2939
"high_limit": 10.0,
3040
"tolerance": 0.1,
3141
}
32-
parameters.update(overrides)
33-
return _state(name, "DeviceWithinLimitsState", parameters)
42+
values.update(overrides)
43+
config = bl_states.DeviceWithinLimitsState.CONFIG_CLASS(name=name, **values)
44+
return _wire_state(bl_states.DeviceWithinLimitsState, config)
45+
46+
47+
def _shutter_state(
48+
name: str = "shutter_open", device: str = "samy"
49+
) -> messages.BeamlineStateConfig:
50+
config = bl_states.ShutterState.CONFIG_CLASS(name=name, device=device)
51+
return _wire_state(bl_states.ShutterState, config)
3452

3553

3654
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):
132150
)
133151

134152

153+
def test_beamline_state_pill_releases_form_on_collapse(qtbot, mocked_client):
154+
limits_pill = create_widget(qtbot, BeamlineStatePill, state_name="limits", client=mocked_client)
155+
limits_pill.set_state_config(_limits_state())
156+
157+
limits_pill.set_expanded(True)
158+
device_widget = limits_pill._config_form.input_widget("device")
159+
160+
limits_pill.set_expanded(False)
161+
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
162+
163+
assert limits_pill._config_form is None
164+
assert not shiboken6.isValid(device_widget)
165+
166+
limits_pill.set_expanded(True)
167+
168+
assert limits_pill._config_form is not None
169+
assert limits_pill._config_form.input_widget("high_limit").value() == 10.0
170+
assert not limits_pill._update_button.isEnabled()
171+
172+
173+
def test_beamline_state_pill_collapse_discards_unsaved_edits(qtbot, mocked_client):
174+
limits_pill = create_widget(qtbot, BeamlineStatePill, state_name="limits", client=mocked_client)
175+
limits_pill.set_state_config(_limits_state())
176+
177+
limits_pill.set_expanded(True)
178+
limits_pill._config_form.input_widget("high_limit").setValue(20.0)
179+
assert limits_pill._update_button.isEnabled()
180+
181+
limits_pill.set_expanded(False)
182+
183+
assert limits_pill._config_form is None
184+
185+
limits_pill.set_expanded(True)
186+
187+
assert limits_pill._config_form.input_widget("high_limit").value() == 10.0
188+
assert not limits_pill._update_button.isEnabled()
189+
190+
135191
def test_beamline_state_pill_does_not_override_themed_input_controls(qtbot, mocked_client):
136192
limits_pill = create_widget(qtbot, BeamlineStatePill, state_name="limits", client=mocked_client)
137193
limits_pill.set_state_config(_limits_state())
@@ -251,13 +307,7 @@ def test_beamline_state_manager_propagates_idle_card_background(qtbot, mocked_cl
251307
def test_beamline_state_manager_filters_status(qtbot, mocked_client):
252308
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
253309
beamline_state_manager.update_available_states(
254-
{
255-
"states": [
256-
_state("shutter_open", "ShutterState", {"device": "samy"}),
257-
_state("limits", "DeviceWithinLimitsState", {"device": "samx"}),
258-
]
259-
},
260-
{},
310+
{"states": [_shutter_state(), _limits_state()]}, {}
261311
)
262312

263313
assert isinstance(beamline_state_manager._toolbar, ModularToolBar)
@@ -324,8 +374,8 @@ def test_beamline_state_manager_filters_devices(qtbot, mocked_client, monkeypatc
324374
beamline_state_manager.update_available_states(
325375
{
326376
"states": [
327-
_state("samx_limits", "DeviceWithinLimitsState", {"device": "samx"}),
328-
_state("samy_limits", "DeviceWithinLimitsState", {"device": "samy"}),
377+
_limits_state(name="samx_limits"),
378+
_limits_state(name="samy_limits", device="samy"),
329379
]
330380
},
331381
{},
@@ -358,6 +408,23 @@ def exec(self):
358408
assert captured["parent"] is beamline_state_manager
359409

360410

411+
def test_beamline_state_manager_collapse_all(qtbot, mocked_client):
412+
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
413+
beamline_state_manager.update_available_states(
414+
{"states": [_limits_state(), _state("shutter_open", "ShutterState", {"device": "samy"})]},
415+
{},
416+
)
417+
418+
for pill in beamline_state_manager._state_pills.values():
419+
pill.set_expanded(True)
420+
assert all(pill.is_expanded() for pill in beamline_state_manager._state_pills.values())
421+
422+
collapse_action = beamline_state_manager._toolbar.components.get_action("collapse_all")
423+
collapse_action.action.trigger()
424+
425+
assert not any(pill.is_expanded() for pill in beamline_state_manager._state_pills.values())
426+
427+
361428
def test_beamline_state_manager_backend_echo_repopulates_expanded_pill(qtbot, mocked_client):
362429
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
363430
beamline_state_manager.update_available_states({"states": [_limits_state()]}, {})

0 commit comments

Comments
 (0)