refactor(widgets): move _call_later from PlotBase to BECWidget

This commit is contained in:
wyzula_j authored and Jan Wyzula committed 2026-10-07 18:36:26 +02:00
1 parent a1f3730c60
commit fd8707d7f5
4 files changed
+68 -41

No files matched your search

+23 -1
View File
@@ -2,13 +2,14 @@ from __future__ import annotations
import os
import tempfile
from collections.abc import Callable
from datetime import datetime
from functools import partial
from typing import TYPE_CHECKING
import shiboken6
from bec_lib.logger import bec_logger
from qtpy.QtCore import QBuffer, QByteArray, QIODevice, QObject, Qt
from qtpy.QtCore import QBuffer, QByteArray, QIODevice, QObject, Qt, QTimer
from qtpy.QtGui import QFont, QPixmap
from qtpy.QtWidgets import QApplication, QFileDialog, QLabel, QVBoxLayout, QWidget
@@ -128,6 +129,27 @@ class BECWidget(BECConnector):
self._update_overlay_theme(theme)
self.apply_theme(theme)
def _call_later(self, msec: int, callback: Callable[[], object]) -> None:
"""
Call ``callback`` once after ``msec`` milliseconds, but only while the widget is in use.
Use this instead of a bare ``QTimer.singleShot`` for deferred work that touches the
widget: the timer is bound to the widget, so the call is dropped if the widget is
deleted first, and it is skipped if the widget has been closed (cleaned up) but not
yet deleted.
Args:
msec(int): Delay in milliseconds.
callback(Callable[[], object]): Called without arguments.
"""
def _call_unless_cleaned_up():
if self._destroyed:
return
callback()
QTimer.singleShot(msec, self, _call_unless_cleaned_up)
def create_busy_state_widget(self) -> QWidget:
"""
Method to create a custom busy state widget to be shown in the busy overlay.
+2 -23
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Callable, Iterable, Mapping
from collections.abc import Iterable, Mapping
from enum import Enum
from typing import Literal, TypeAlias
@@ -9,7 +9,7 @@ import pyqtgraph as pg
from bec_lib import bec_logger
from bec_lib.scan_data_container import ScanDataContainer
from bec_lib.scan_items import ScanItem
from qtpy.QtCore import QPoint, QPointF, Qt, QTimer, Signal
from qtpy.QtCore import QPoint, QPointF, Qt, Signal
from qtpy.QtWidgets import QHBoxLayout, QLabel, QMainWindow, QVBoxLayout, QWidget
from bec_widgets.utils.bec_connector import ConnectionConfig
@@ -1375,27 +1375,6 @@ class PlotBase(BECWidget, QWidget):
self.crosshair.clear_markers()
self.crosshair.update_markers()
def _call_later(self, msec: int, callback: Callable[[], object]) -> None:
"""
Call ``callback`` once after ``msec`` milliseconds, but only while the widget is in use.
Use this instead of a bare ``QTimer.singleShot`` for deferred work that touches the
widget: the timer is bound to the widget, so the call is dropped if the widget is
deleted first, and it is skipped if the widget has been closed (cleaned up) but not
yet deleted.
Args:
msec(int): Delay in milliseconds.
callback(Callable[[], object]): Called without arguments.
"""
def _call_unless_cleaned_up():
if self._destroyed:
return
callback()
QTimer.singleShot(msec, self, _call_unless_cleaned_up)
def cleanup(self):
self.toolbar.cleanup()
self.unhook_crosshair(preserve_pin=False)
@@ -243,3 +243,46 @@ def test_parent_destruction_purges_dead_dispatcher_slots(qtbot, mocked_client, b
for slot in bec_dispatcher._registered_slots.values():
owner = getattr(slot.cb, "__self__", None)
assert not isinstance(owner, QObject) or shiboken6.isValid(owner)
def test_call_later_runs_only_while_widget_is_open(qtbot, mocked_client):
"""
_call_later runs the callback while the widget is open and skips it once the widget has
been closed (cleaned up) or deleted before the delay elapsed.
"""
open_widget = LifecycleWidget(client=mocked_client)
closed_widget = LifecycleWidget(client=mocked_client)
deleted_widget = LifecycleWidget(client=mocked_client)
calls = []
open_widget._call_later(10, lambda: calls.append("open"))
closed_widget._call_later(10, lambda: calls.append("closed"))
deleted_widget._call_later(10, lambda: calls.append("deleted"))
closed_widget.close() # closeEvent -> cleanup() before the callback is due
deleted_widget.deleteLater()
_flush_deferred_deletes(qtbot)
qtbot.wait(100)
assert calls == ["open"]
for widget in (open_widget, closed_widget):
widget.close()
widget.deleteLater()
_flush_deferred_deletes(qtbot)
def test_call_later_dropped_when_parent_destruction_deletes_widget(qtbot, mocked_client):
"""
Destroying a plain parent deletes the BECWidget child without a close event, so the
cleaned-up flag is never set; the timer is bound to the widget and Qt drops the call.
"""
parent = QWidget()
child = LifecycleWidget(parent=parent, client=mocked_client)
calls = []
child._call_later(10, lambda: calls.append("child"))
parent.deleteLater()
_flush_deferred_deletes(qtbot)
qtbot.wait(100)
assert calls == []
@@ -792,20 +792,3 @@ def test_limits_accept_fractional_values(qtbot, mocked_client):
pb.y_limits = (-1.75, 3.5)
assert (pb.x_limits.x(), pb.x_limits.y()) == (0.5, 9.25)
assert (pb.y_limits.x(), pb.y_limits.y()) == (-1.75, 3.5)
def test_call_later_skips_callback_once_widget_is_closed(qtbot, mocked_client):
"""
PlotBase._call_later runs the deferred callback while the widget is open and skips it
when the widget has been closed (cleaned up) before the delay elapsed.
"""
open_pb = create_widget(qtbot, PlotBase, client=mocked_client)
closed_pb = create_widget(qtbot, PlotBase, client=mocked_client)
calls = []
open_pb._call_later(10, lambda: calls.append("open"))
closed_pb._call_later(10, lambda: calls.append("closed"))
closed_pb.close() # closeEvent -> cleanup() before the callback is due
qtbot.wait(100)
assert calls == ["open"]