From 252337c242825475f94fb3c85546aae4da3128f0 Mon Sep 17 00:00:00 2001 From: Johannes Kasimir Date: Fri, 7 Aug 2026 15:22:09 +0200 Subject: [PATCH] fix: make debouncer syncronous if there's no running loop --- pyproject.toml | 1 - src/plopp/widgets/clip3d.py | 10 +---- src/plopp/widgets/debounce.py | 73 +++++++++++++--------------------- tests/widgets/clip3d_test.py | 3 -- tests/widgets/debounce_test.py | 58 +++++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 57 deletions(-) create mode 100644 tests/widgets/debounce_test.py diff --git a/pyproject.toml b/pyproject.toml index 61f905e77..abfd51b00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,6 @@ filterwarnings = [ 'ignore:Passing unrecognized arguments to super:DeprecationWarning', 'ignore:Jupyter is migrating its paths:DeprecationWarning', 'ignore:setDaemon\(\) is deprecated, set the daemon attribute instead:DeprecationWarning', - 'ignore:There is no current event loop:DeprecationWarning', # Should be removed once https://github.com/ipython/ipykernel/pull/1248 is released 'ignore:Parsing dates involving a day of month without a year:DeprecationWarning', # Should be removed once we lower pin a matplotlib version that respects the deprecation diff --git a/src/plopp/widgets/clip3d.py b/src/plopp/widgets/clip3d.py index 4c57dae2a..8cb646dbe 100644 --- a/src/plopp/widgets/clip3d.py +++ b/src/plopp/widgets/clip3d.py @@ -74,6 +74,7 @@ def __init__( self._unit = self._limits[axis].unit self.visible = True self._update = update + self._throttled_update = debounce(update, wait=0.3) self._border_visible = border_visible w_axis = 2 if self.kind == 'x' else 0 @@ -189,10 +190,6 @@ def make_selection(self, da: sc.DataArray) -> sc.Variable: xmin, xmax = self.range return (da.coords[self.dim] >= xmin) & (da.coords[self.dim] < xmax) - @debounce(0.3) - def _throttled_update(self): - self._update() - class ClipValueTool(ipw.HBox): """ @@ -215,6 +212,7 @@ def __init__(self, limits: sc.Variable, update: Callable): self._unit = self._limits.unit self.visible = True self._update = update + self._throttled_update = debounce(update, wait=0.3) self.kind = 'v' center = self._limits.mean().value @@ -280,10 +278,6 @@ def move(self, change: dict[str, Any]): return self._throttled_update() - @debounce(0.3) - def _throttled_update(self): - self._update() - class ClippingManager(ipw.HBox): """ diff --git a/src/plopp/widgets/debounce.py b/src/plopp/widgets/debounce.py index 77ed5de24..d4025df83 100644 --- a/src/plopp/widgets/debounce.py +++ b/src/plopp/widgets/debounce.py @@ -3,53 +3,36 @@ import asyncio from collections.abc import Callable +from functools import wraps -class Timer: +def debounce(fn: Callable, *, wait: float): """ - From: - https://ipywidgets.readthedocs.io/en/8.0.2/examples/Widget%20Events.html#Debouncing - """ - - def __init__(self, timeout: float, callback: Callable): - self._timeout = timeout - self._callback = callback - - async def _job(self): - await asyncio.sleep(self._timeout) - self._callback() - - def start(self): - self._task = asyncio.ensure_future(self._job()) - - def cancel(self): - self._task.cancel() - - -def debounce(wait: float): - """ - Decorator that will postpone a function's - execution until after `wait` seconds - have elapsed since the last time it was invoked. + Wrap a function so that its execution is postponed until `wait` seconds have + elapsed since the last time it was invoked. - From: - https://ipywidgets.readthedocs.io/en/8.0.2/examples/Widget%20Events.html#Debouncing + If there is no running event loop, the function is called immediately. This is + useful in synchronous contexts such as tests and scripts, where delayed execution + cannot be scheduled without blocking or using another thread. """ - - def decorator(fn: Callable): - timer = None - - def debounced(*args, **kwargs): - nonlocal timer - - def call_it(): - fn(*args, **kwargs) - - if timer is not None: - timer.cancel() - timer = Timer(wait, call_it) - timer.start() - - return debounced - - return decorator + handle: asyncio.TimerHandle | None = None + + @wraps(fn) + def debounced(*args, **kwargs): + nonlocal handle + + def call_it(): + nonlocal handle + handle = None + fn(*args, **kwargs) + + if handle is not None: + handle.cancel() + try: + loop = asyncio.get_running_loop() + except RuntimeError: + call_it() + else: + handle = loop.call_later(wait, call_it) + + return debounced diff --git a/tests/widgets/clip3d_test.py b/tests/widgets/clip3d_test.py index 8dfb864fe..ab5d0b185 100644 --- a/tests/widgets/clip3d_test.py +++ b/tests/widgets/clip3d_test.py @@ -60,7 +60,6 @@ def test_value_cuts(multiple_nodes): vcut = clip.cuts[-1] npoints = list(fig.artists.values())[-1]._data.shape[0] vcut.slider.value = [vcut.slider.min, vcut.slider.value[1]] - clip.update_state() # Need to manually update state due to debounce mechanism # We should now have more points in the cut than before because the range is wider npoints2 = list(fig.artists.values())[-1]._data.shape[0] assert npoints2 > npoints @@ -73,7 +72,6 @@ def test_value_cuts(multiple_nodes): 0.5 * (vcut2.slider.value[1] + vcut2.slider.max), vcut2.slider.max, ] - clip.update_state() # Need to manually update state due to debounce mechanism # We should now have more points in the cut than before because the range is wider npoints3 = list(fig.artists.values())[-1]._data.shape[0] assert npoints3 > npoints2 @@ -130,7 +128,6 @@ def test_move_cut(): xcut.slider.value = [xcut.slider.min, xcut.slider.value[1]] assert xcut.outlines[0].position[0] == xcut.slider.value[0] assert xcut.outlines[1].position[0] == xcut.slider.value[1] - clip.update_state() # Need to manually update state due to debounce mechanism new_pts = list(fig.artists.values())[-1] assert npoints < new_pts._data.shape[0] diff --git a/tests/widgets/debounce_test.py b/tests/widgets/debounce_test.py new file mode 100644 index 000000000..e6bdbb494 --- /dev/null +++ b/tests/widgets/debounce_test.py @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Scipp contributors (https://github.com/scipp) + +import asyncio + +from plopp.widgets.debounce import debounce + + +def test_calls_immediately_without_running_event_loop(): + calls = [] + + def callback(value): + calls.append(value) + + debounced = debounce(callback, wait=0.01) + debounced('first') + debounced('second') + + assert calls == ['first', 'second'] + + +def test_delays_call_and_keeps_latest_value_with_running_event_loop(): + async def run(): + calls = [] + called = asyncio.Event() + + def callback(value): + calls.append(value) + called.set() + + debounced = debounce(callback, wait=0.01) + debounced('discarded') + debounced('called') + assert calls == [] + await asyncio.wait_for(called.wait(), timeout=1.0) + return calls + + assert asyncio.run(run()) == ['called'] + + +def test_independent_callbacks_do_not_cancel_each_other(): + async def run(): + calls = [] + both_called = asyncio.Event() + + def callback(value): + calls.append(value) + if len(calls) == 2: + both_called.set() + + first = debounce(callback, wait=0.01) + second = debounce(callback, wait=0.01) + first('first') + second('second') + await asyncio.wait_for(both_called.wait(), timeout=1.0) + return calls + + assert sorted(asyncio.run(run())) == ['first', 'second']