Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 2 additions & 8 deletions src/plopp/widgets/clip3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
"""
Expand All @@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down
73 changes: 28 additions & 45 deletions src/plopp/widgets/debounce.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 0 additions & 3 deletions tests/widgets/clip3d_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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]

Expand Down
58 changes: 58 additions & 0 deletions tests/widgets/debounce_test.py
Original file line number Diff line number Diff line change
@@ -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']
Loading