pythonnative 0.16.0__py3-none-any.whl → 0.17.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pythonnative/animated.py CHANGED
@@ -1,46 +1,42 @@
1
1
  """Animated values + animation drivers + animated component wrappers.
2
2
 
3
- Modeled on React Native's `Animated` API. The core primitives are:
3
+ Modeled on React Native's ``Animated`` API but with an
4
+ ``async``-aware completion contract. The core primitives are:
4
5
 
5
6
  - [`AnimatedValue`][pythonnative.animated.AnimatedValue]: a numeric
6
7
  cell with subscribers; animations mutate it over time.
7
8
  - ``Animated.timing`` / ``Animated.spring`` / ``Animated.decay``:
8
- animation factories.
9
+ animation factories. The objects they return implement
10
+ ``__await__``, so you can write ``await Animated.timing(v, to=1.0)``
11
+ to suspend until the animation finishes.
9
12
  - ``Animated.sequence`` / ``Animated.parallel`` / ``Animated.delay``:
10
- composition.
13
+ composition; also awaitable.
11
14
  - ``Animated.View`` / ``Animated.Text`` / ``Animated.Image``:
12
15
  components whose ``style`` may contain ``AnimatedValue`` instances.
13
- The component subscribes to the value during mount and forwards
14
- changes directly to the underlying native handler's
15
- ``set_animated_property`` hook (bypassing the reconciler so
16
- per-frame work doesn't go through full Python reconciliation).
17
16
 
18
17
  Driver:
19
18
 
20
- - A single background thread ticks at ~60 Hz, advancing every
21
- active animation by ``dt``. When an animation finishes it removes
22
- itself from the active set; the thread sleeps when nothing is
23
- running.
24
- - For platforms that have a native easing/animation API,
25
- ``AnimatedValue`` *also* sends a one-shot
26
- ``set_animated_property(view, prop, target, duration_ms, easing)``
27
- call when the animation starts, so UIKit / Android can interpolate
28
- at GPU 60 Hz without per-frame Python work. The Python ticker
29
- then keeps the reactive ``AnimatedValue.value`` reading
30
- approximately synchronized for any non-native consumers.
19
+ - A single background thread ticks at ~60 Hz, advancing every active
20
+ animation by ``dt``.
21
+ - Animations expose two APIs:
22
+ - ``handle.start()`` — fire-and-forget. Returns ``self``.
23
+ - ``await handle`` (or ``await handle.run()``) wait for the
24
+ animation to complete; cancellation cancels the animation.
31
25
 
32
26
  Example:
33
27
  ```python
34
28
  import pythonnative as pn
35
29
 
30
+
36
31
  @pn.component
37
32
  def FadeIn():
38
- opacity = pn.use_memo(lambda: pn.Animated.Value(0.0), [])
33
+ opacity = pn.use_animated_value(0.0)
39
34
 
40
- def fade_in():
41
- pn.Animated.timing(opacity, to=1.0, duration=400).start()
35
+ async def fade_in():
36
+ await pn.Animated.timing(opacity, to=1.0, duration=400)
37
+ await pn.Animated.timing(opacity, to=0.5, duration=200)
42
38
 
43
- pn.use_effect(fade_in, [])
39
+ pn.use_async_effect(fade_in, [])
44
40
 
45
41
  return pn.Animated.View(
46
42
  pn.Text("Hello!"),
@@ -51,6 +47,7 @@ Example:
51
47
 
52
48
  from __future__ import annotations
53
49
 
50
+ import asyncio
54
51
  import math
55
52
  import threading
56
53
  import time
@@ -58,14 +55,19 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
58
55
 
59
56
  from .element import Element
60
57
  from .hooks import use_effect, use_ref
58
+ from .runtime import resolve_future
61
59
  from .style import StyleProp, resolve_style
62
60
 
63
61
  # Maximum frame rate at which the Python ticker drives animations.
64
- # We aim for 60 Hz but back off when no animation is active.
65
62
  _TARGET_FPS = 60.0
66
63
  _FRAME_DT = 1.0 / _TARGET_FPS
67
64
 
68
- # Easing functions: t in [0, 1] -> [0, 1].
65
+ # Upper bound on how much wall-clock time the animation loop will try to
66
+ # catch up on in a single iteration after thread starvation. At 60 fps
67
+ # this is ~333 ms of simulated motion; further drift is dropped to keep
68
+ # the loop responsive.
69
+ _MAX_CATCHUP_FRAMES = 20
70
+
69
71
  _EASINGS: Dict[str, Callable[[float], float]] = {
70
72
  "linear": lambda t: t,
71
73
  "ease_in": lambda t: t * t,
@@ -104,13 +106,14 @@ def _resolve_easing(name: Any) -> Callable[[float], float]:
104
106
  class AnimatedValue:
105
107
  """A subscribable numeric cell driven by animations.
106
108
 
107
- Direct mutation via [`set_value`][pythonnative.animated.AnimatedValue.set_value]
108
- fires subscribers immediately; animations call `set_value` from
109
+ Direct mutation via
110
+ [`set_value`][pythonnative.animated.AnimatedValue.set_value]
111
+ fires subscribers immediately; animations call ``set_value`` from
109
112
  the ticker thread.
110
113
 
111
114
  Subscribers are ``(prop_name, callback)`` tuples. Each animated
112
- component (e.g., `Animated.View`) subscribes once per
113
- AnimatedValue prop in its style during mount.
115
+ component (e.g., ``Animated.View``) subscribes once per
116
+ ``AnimatedValue`` prop in its style during mount.
114
117
  """
115
118
 
116
119
  __slots__ = ("_value", "_subscribers", "_lock")
@@ -126,11 +129,7 @@ class AnimatedValue:
126
129
  return self._value
127
130
 
128
131
  def set_value(self, new_value: float) -> None:
129
- """Set the value immediately and fire all subscribers.
130
-
131
- Used by user code for instant snaps; animations also call this
132
- once per tick to update the value.
133
- """
132
+ """Set the value immediately and fire all subscribers."""
134
133
  new_value = float(new_value)
135
134
  with self._lock:
136
135
  self._value = new_value
@@ -146,8 +145,7 @@ class AnimatedValue:
146
145
 
147
146
  Returns an unsubscribe callable. ``prop`` is metadata only —
148
147
  it lets the subscriber differentiate this binding from others
149
- on the same AnimatedValue (the value can be bound to
150
- multiple props on multiple views).
148
+ on the same ``AnimatedValue``.
151
149
  """
152
150
  with self._lock:
153
151
  self._subscribers.append((prop, callback))
@@ -176,9 +174,9 @@ class AnimatedValue:
176
174
  class _AnimationManager:
177
175
  """Single-threaded driver for all currently-running animations.
178
176
 
179
- Holds a list of ``(animation, advance_callback)`` pairs and
180
- ticks them at ~60 Hz. The thread starts on first use and idles
181
- (releases the GIL via ``time.sleep``) when nothing is active.
177
+ Holds a list of ``_RunningAnimation`` instances and ticks them at
178
+ ~60 Hz. The thread starts on first use and idles when nothing is
179
+ active.
182
180
  """
183
181
 
184
182
  def __init__(self) -> None:
@@ -207,6 +205,20 @@ class _AnimationManager:
207
205
 
208
206
  def _loop(self) -> None:
209
207
  last = time.monotonic()
208
+ # Clamping the per-tick dt is important for numerical stability:
209
+ # an underdamped spring with a 0.3 s step explodes immediately,
210
+ # and on iOS/Android the animation thread can be starved for
211
+ # several frames during render bursts. We integrate physics on a
212
+ # clamped dt (max 2 target frames) and sub-step when wall-clock
213
+ # has advanced more than that, so the perceived motion still
214
+ # tracks real time at most a couple of frames behind. After an
215
+ # extreme starvation (e.g. the app was backgrounded for seconds)
216
+ # we cap the catch-up at ``_MAX_CATCHUP_FRAMES`` worth of
217
+ # physics; any further wall-clock drift is dropped on the floor,
218
+ # which keeps the loop responsive instead of spinning forward
219
+ # through hundreds of substeps.
220
+ max_step = _FRAME_DT * 2.0
221
+ max_catchup = _FRAME_DT * _MAX_CATCHUP_FRAMES
210
222
  while not self._stopped:
211
223
  now = time.monotonic()
212
224
  dt = now - last
@@ -214,17 +226,22 @@ class _AnimationManager:
214
226
  with self._lock:
215
227
  active = list(self._animations)
216
228
  if not active:
217
- # Idle: sleep longer until something starts.
218
229
  time.sleep(0.05)
219
230
  last = time.monotonic()
220
231
  continue
221
- for anim in active:
222
- try:
223
- finished = anim.advance(dt)
224
- except Exception:
225
- finished = True
226
- if finished:
227
- self.remove(anim)
232
+ remaining = min(dt, max_catchup)
233
+ while remaining > 0.0:
234
+ step = remaining if remaining <= max_step else max_step
235
+ remaining -= step
236
+ for anim in active:
237
+ if getattr(anim, "_completed", False):
238
+ continue
239
+ try:
240
+ finished = anim.advance(step)
241
+ except Exception:
242
+ finished = True
243
+ if finished:
244
+ self.remove(anim)
228
245
  time.sleep(_FRAME_DT)
229
246
 
230
247
 
@@ -237,21 +254,28 @@ _manager = _AnimationManager()
237
254
 
238
255
 
239
256
  class _RunningAnimation:
240
- """Base class for in-flight animations; advance() returns True when done."""
257
+ """Base class for in-flight animations; ``advance()`` returns True when done."""
241
258
 
242
- def __init__(self, value: AnimatedValue, on_complete: Optional[Callable[[], None]]) -> None:
259
+ def __init__(self, value: AnimatedValue) -> None:
243
260
  self.value = value
244
- self._on_complete = on_complete
261
+ self._completion_futures: List[asyncio.Future[None]] = []
262
+ self._completed = False
263
+
264
+ def add_completion_future(self, future: asyncio.Future[None]) -> None:
265
+ """Register ``future`` to be resolved when the animation ends."""
266
+ self._completion_futures.append(future)
267
+ if self._completed:
268
+ resolve_future(future, None)
245
269
 
246
270
  def advance(self, dt: float) -> bool:
247
271
  raise NotImplementedError
248
272
 
249
273
  def _finish(self) -> None:
250
- if self._on_complete is not None:
251
- try:
252
- self._on_complete()
253
- except Exception:
254
- pass
274
+ if self._completed:
275
+ return
276
+ self._completed = True
277
+ for fut in self._completion_futures:
278
+ resolve_future(fut, None)
255
279
 
256
280
 
257
281
  class _TimingAnimation(_RunningAnimation):
@@ -261,9 +285,8 @@ class _TimingAnimation(_RunningAnimation):
261
285
  to: float,
262
286
  duration: float,
263
287
  easing: Callable[[float], float],
264
- on_complete: Optional[Callable[[], None]],
265
288
  ) -> None:
266
- super().__init__(value, on_complete)
289
+ super().__init__(value)
267
290
  self._from = value.value
268
291
  self._to = float(to)
269
292
  self._duration = max(0.001, float(duration) / 1000.0)
@@ -292,9 +315,8 @@ class _SpringAnimation(_RunningAnimation):
292
315
  stiffness: float,
293
316
  damping: float,
294
317
  mass: float,
295
- on_complete: Optional[Callable[[], None]],
296
318
  ) -> None:
297
- super().__init__(value, on_complete)
319
+ super().__init__(value)
298
320
  self._to = float(to)
299
321
  self._velocity = 0.0
300
322
  self._stiffness = float(stiffness)
@@ -316,20 +338,13 @@ class _SpringAnimation(_RunningAnimation):
316
338
 
317
339
 
318
340
  class _DecayAnimation(_RunningAnimation):
319
- def __init__(
320
- self,
321
- value: AnimatedValue,
322
- velocity: float,
323
- deceleration: float,
324
- on_complete: Optional[Callable[[], None]],
325
- ) -> None:
326
- super().__init__(value, on_complete)
341
+ def __init__(self, value: AnimatedValue, velocity: float, deceleration: float) -> None:
342
+ super().__init__(value)
327
343
  self._velocity = float(velocity)
328
344
  self._deceleration = float(deceleration)
329
345
  self._rest_threshold = 0.001
330
346
 
331
347
  def advance(self, dt: float) -> bool:
332
- # Exponential decay of velocity.
333
348
  self._velocity *= math.exp(-self._deceleration * dt * 1000.0)
334
349
  new_x = self.value.value + self._velocity * dt
335
350
  self.value.set_value(new_x)
@@ -339,94 +354,150 @@ class _DecayAnimation(_RunningAnimation):
339
354
  return False
340
355
 
341
356
 
342
- class _CompositeAnimation:
343
- """Wraps a list of animations played in sequence or in parallel."""
357
+ class _DelayAnimation(_RunningAnimation):
358
+ def __init__(self, duration_ms: float) -> None:
359
+ super().__init__(AnimatedValue(0.0))
360
+ self._elapsed = 0.0
361
+ self._duration = max(0.001, duration_ms / 1000.0)
344
362
 
345
- def __init__(self, items: List[Any], mode: str) -> None:
346
- self._items = list(items)
347
- self._mode = mode
363
+ def advance(self, dt: float) -> bool:
364
+ self._elapsed += dt
365
+ if self._elapsed >= self._duration:
366
+ self._finish()
367
+ return True
368
+ return False
348
369
 
349
- def start(self, on_complete: Optional[Callable[[], None]] = None) -> None:
350
- if self._mode == "parallel":
351
- remaining = [len(self._items)]
352
- lock = threading.Lock()
353
-
354
- def _one_done() -> None:
355
- with lock:
356
- remaining[0] -= 1
357
- if remaining[0] <= 0 and on_complete is not None:
358
- try:
359
- on_complete()
360
- except Exception:
361
- pass
362
-
363
- for item in self._items:
364
- if item is None:
365
- _one_done()
366
- continue
367
- try:
368
- item.start(_one_done)
369
- except Exception:
370
- _one_done()
371
- return
372
370
 
373
- # Sequence
374
- index = [0]
371
+ # ======================================================================
372
+ # Public animation handles
373
+ # ======================================================================
375
374
 
376
- def _next() -> None:
377
- i = index[0]
378
- if i >= len(self._items):
379
- if on_complete is not None:
380
- try:
381
- on_complete()
382
- except Exception:
383
- pass
384
- return
385
- item = self._items[i]
386
- index[0] += 1
387
- if item is None:
388
- _next()
389
- return
390
- try:
391
- item.start(_next)
392
- except Exception:
393
- _next()
394
375
 
395
- _next()
376
+ class _AwaitableAnimation:
377
+ """Base for awaitable animation handles.
378
+
379
+ Subclasses implement :meth:`start` and :meth:`stop`. Awaiting the
380
+ handle (``await handle``) starts the animation if necessary and
381
+ suspends until it completes. Cancelling the awaiting task calls
382
+ :meth:`stop`.
383
+
384
+ Calling :meth:`start` returns ``self`` so handles can be chained
385
+ or stashed: ``handle = pn.Animated.timing(...).start()``.
386
+ """
387
+
388
+ def start(self) -> "_AwaitableAnimation":
389
+ raise NotImplementedError
396
390
 
397
391
  def stop(self) -> None:
398
- for item in self._items:
392
+ raise NotImplementedError
393
+
394
+ def run(self) -> "_AwaitableAnimation":
395
+ """Return ``self`` for explicit ``await handle.run()`` style.
396
+
397
+ Equivalent to ``await handle`` directly; provided because some
398
+ readers prefer the slightly more explicit form, particularly
399
+ when storing the awaitable before resolving it.
400
+ """
401
+ return self
402
+
403
+ async def _drive(self) -> None:
404
+ raise NotImplementedError
405
+
406
+ def __await__(self) -> Any:
407
+ try:
408
+ asyncio.get_running_loop()
409
+ except RuntimeError as exc:
410
+ raise RuntimeError(
411
+ "Animations can only be awaited from inside an asyncio task; "
412
+ "use handle.start() to fire-and-forget instead."
413
+ ) from exc
414
+
415
+ async def _runner() -> None:
399
416
  try:
400
- item.stop()
401
- except Exception:
402
- pass
417
+ await self._drive()
418
+ except asyncio.CancelledError:
419
+ self.stop()
420
+ raise
421
+
422
+ return _runner().__await__()
403
423
 
404
424
 
405
- class _AnimationHandle:
406
- """Public handle returned by `Animated.timing` / `.spring` / `.decay`.
425
+ class _AnimationHandle(_AwaitableAnimation):
426
+ """Public handle returned by ``Animated.timing`` / ``.spring`` / ``.decay``.
407
427
 
408
- Wraps a `_RunningAnimation` factory so each ``.start()`` call creates
409
- a fresh in-flight animation (matches RN — the `Animated.timing`
410
- return value is reusable).
428
+ Wraps a ``_RunningAnimation`` factory so each ``.start()`` call
429
+ creates a fresh in-flight animation (matches React Native — the
430
+ ``Animated.timing`` return value is reusable).
411
431
  """
412
432
 
413
- def __init__(self, factory: Callable[[Optional[Callable[[], None]]], _RunningAnimation]) -> None:
433
+ def __init__(self, factory: Callable[[], _RunningAnimation]) -> None:
414
434
  self._factory = factory
415
435
  self._current: Optional[_RunningAnimation] = None
416
436
 
417
- def start(self, on_complete: Optional[Callable[[], None]] = None) -> None:
418
- """Begin the animation, optionally invoking ``on_complete`` at the end."""
437
+ def start(self) -> "_AnimationHandle":
438
+ """Begin the animation. Returns ``self`` for chaining."""
419
439
  self.stop()
420
- anim = self._factory(on_complete)
440
+ anim = self._factory()
421
441
  self._current = anim
422
442
  _manager.add(anim)
443
+ return self
423
444
 
424
445
  def stop(self) -> None:
425
446
  """Cancel the running instance (no-op if not running)."""
426
447
  if self._current is not None:
448
+ self._current._finish()
427
449
  _manager.remove(self._current)
428
450
  self._current = None
429
451
 
452
+ async def _drive(self) -> None:
453
+ if self._current is None:
454
+ self.start()
455
+ loop = asyncio.get_running_loop()
456
+ future: asyncio.Future[None] = loop.create_future()
457
+ assert self._current is not None
458
+ self._current.add_completion_future(future)
459
+ await future
460
+
461
+
462
+ class _CompositeAnimation(_AwaitableAnimation):
463
+ """Run a list of animations in sequence or in parallel."""
464
+
465
+ def __init__(self, items: List[Any], mode: str) -> None:
466
+ self._items = list(items)
467
+ self._mode = mode
468
+
469
+ def start(self) -> "_CompositeAnimation":
470
+ """Schedule the composite on the framework runtime, fire-and-forget."""
471
+ from .runtime import run_async
472
+
473
+ run_async(self._drive())
474
+ return self
475
+
476
+ def stop(self) -> None:
477
+ for item in self._items:
478
+ try:
479
+ item.stop()
480
+ except Exception:
481
+ pass
482
+
483
+ async def _drive(self) -> None:
484
+ if self._mode == "parallel":
485
+ await asyncio.gather(*(self._await_item(item) for item in self._items))
486
+ return
487
+ for item in self._items:
488
+ await self._await_item(item)
489
+
490
+ @staticmethod
491
+ async def _await_item(item: Any) -> None:
492
+ if item is None:
493
+ return
494
+ if isinstance(item, _AwaitableAnimation):
495
+ await item
496
+ else:
497
+ # Plain awaitables and coroutines are supported too — lets
498
+ # users mix in ``asyncio.sleep`` or other awaitables.
499
+ await item
500
+
430
501
 
431
502
  # ======================================================================
432
503
  # Animated component wrappers
@@ -434,10 +505,10 @@ class _AnimationHandle:
434
505
 
435
506
 
436
507
  def _resolve_style_with_values(style: StyleProp) -> Tuple[Dict[str, Any], Dict[str, AnimatedValue]]:
437
- """Return ``(plain_style, animated_bindings)``.
508
+ """Split ``style`` into a plain dict and animated bindings.
438
509
 
439
- AnimatedValue entries in the style are replaced with their
440
- current numeric value in ``plain_style`` and recorded in
510
+ AnimatedValue entries in the style are replaced with their current
511
+ numeric value in ``plain_style`` and recorded in
441
512
  ``animated_bindings`` so the wrapping component can subscribe
442
513
  after mount.
443
514
  """
@@ -457,11 +528,7 @@ def _make_animated_factory(
457
528
  element_type: str,
458
529
  accept_children: bool,
459
530
  ) -> Callable[..., Element]:
460
- """Build an animated wrapper for ``element_type``.
461
-
462
- The returned factory is used as the public
463
- ``Animated.View`` / ``Animated.Text`` / ``Animated.Image``.
464
- """
531
+ """Build an animated wrapper for ``element_type``."""
465
532
  from .hooks import component # local import to avoid cycle
466
533
 
467
534
  @component
@@ -482,9 +549,9 @@ def _make_animated_factory(
482
549
  return lambda: None
483
550
 
484
551
  for prop, value in bindings.items():
485
- # Capture into closure via default arg.
552
+
486
553
  def _on_change(new_val: float, _prop: str = prop, _view: Any = view) -> None:
487
- handler = _get_handler_for(view)
554
+ handler = _get_handler_for(_view)
488
555
  if handler is None:
489
556
  return
490
557
  setter = getattr(handler, "set_animated_property", None)
@@ -516,7 +583,6 @@ def _make_animated_factory(
516
583
  if element_type == "Image":
517
584
  source = args[0] if args else kwargs.pop("source", "")
518
585
  return _Image(source, style=plain_style, ref=ref)
519
- # View
520
586
  children = list(args) if accept_children else []
521
587
  return _View(*children, style=plain_style, ref=ref)
522
588
 
@@ -524,29 +590,19 @@ def _make_animated_factory(
524
590
 
525
591
 
526
592
  def _animated_prop_name(prop: str) -> str:
527
- """Map a style key to the name expected by `set_animated_property`."""
593
+ """Map a style key to the name expected by ``set_animated_property``."""
528
594
  if prop == "opacity":
529
595
  return "opacity"
530
596
  if prop == "background_color":
531
597
  return "background_color"
532
- # Transform shorthand keys: ``translate_x``, ``translate_y``,
533
- # ``scale``, ``scale_x``, ``scale_y``, ``rotate``.
534
598
  if prop in ("translate_x", "translate_y", "scale", "scale_x", "scale_y", "rotate"):
535
599
  return prop
536
600
  return prop
537
601
 
538
602
 
539
603
  def _get_handler_for(native_view: Any) -> Any:
540
- """Best-effort lookup of the registered handler for ``native_view``.
541
-
542
- Animated bindings need a handler reference to call
543
- `set_animated_property`. Since the registry is keyed by element
544
- type and we only have the native view, we fall back to looking
545
- up the most recently registered "View" handler — works in
546
- practice because all animated targets are flex containers,
547
- images, or text views, and every iOS/Android handler subclass
548
- inherits the same `set_animated_property` from the base.
549
- """
604
+ """Best-effort lookup of the registered handler for ``native_view``."""
605
+ del native_view
550
606
  try:
551
607
  from .native_views import get_registry
552
608
 
@@ -570,8 +626,8 @@ def _get_handler_for(native_view: Any) -> Any:
570
626
  class _AnimatedNamespace:
571
627
  """Public ``Animated`` namespace.
572
628
 
573
- Exposes the `Value`, animation factories, composers, and
574
- component wrappers (`View`, `Text`, `Image`).
629
+ Exposes the ``Value`` type, animation factories, composers, and
630
+ component wrappers (``View``, ``Text``, ``Image``).
575
631
  """
576
632
 
577
633
  Value = AnimatedValue
@@ -586,8 +642,8 @@ class _AnimatedNamespace:
586
642
  ) -> _AnimationHandle:
587
643
  """Linearly interpolate ``value`` to ``to`` over ``duration`` ms."""
588
644
 
589
- def _factory(on_complete: Optional[Callable[[], None]]) -> _RunningAnimation:
590
- return _TimingAnimation(value, to, duration, _resolve_easing(easing), on_complete)
645
+ def _factory() -> _RunningAnimation:
646
+ return _TimingAnimation(value, to, duration, _resolve_easing(easing))
591
647
 
592
648
  return _AnimationHandle(_factory)
593
649
 
@@ -602,8 +658,8 @@ class _AnimatedNamespace:
602
658
  ) -> _AnimationHandle:
603
659
  """Run a damped harmonic spring toward ``to``."""
604
660
 
605
- def _factory(on_complete: Optional[Callable[[], None]]) -> _RunningAnimation:
606
- return _SpringAnimation(value, to, stiffness, damping, mass, on_complete)
661
+ def _factory() -> _RunningAnimation:
662
+ return _SpringAnimation(value, to, stiffness, damping, mass)
607
663
 
608
664
  return _AnimationHandle(_factory)
609
665
 
@@ -616,8 +672,8 @@ class _AnimatedNamespace:
616
672
  ) -> _AnimationHandle:
617
673
  """Decelerate ``value`` from its current velocity until it rests."""
618
674
 
619
- def _factory(on_complete: Optional[Callable[[], None]]) -> _RunningAnimation:
620
- return _DecayAnimation(value, velocity, deceleration, on_complete)
675
+ def _factory() -> _RunningAnimation:
676
+ return _DecayAnimation(value, velocity, deceleration)
621
677
 
622
678
  return _AnimationHandle(_factory)
623
679
 
@@ -635,21 +691,8 @@ class _AnimatedNamespace:
635
691
  def delay(duration: float) -> _AnimationHandle:
636
692
  """Wait ``duration`` ms before continuing in a sequence."""
637
693
 
638
- def _factory(on_complete: Optional[Callable[[], None]]) -> _RunningAnimation:
639
- class _Delay(_RunningAnimation):
640
- def __init__(self, on_complete: Optional[Callable[[], None]]) -> None:
641
- super().__init__(AnimatedValue(0.0), on_complete)
642
- self._elapsed = 0.0
643
- self._duration = max(0.001, duration / 1000.0)
644
-
645
- def advance(self, dt: float) -> bool:
646
- self._elapsed += dt
647
- if self._elapsed >= self._duration:
648
- self._finish()
649
- return True
650
- return False
651
-
652
- return _Delay(on_complete)
694
+ def _factory() -> _RunningAnimation:
695
+ return _DelayAnimation(duration)
653
696
 
654
697
  return _AnimationHandle(_factory)
655
698
 
@@ -662,7 +705,7 @@ Animated = _AnimatedNamespace()
662
705
 
663
706
 
664
707
  def use_animated_value(initial: float = 0.0) -> AnimatedValue:
665
- """Return an [`AnimatedValue`][pythonnative.AnimatedValue] with a stable identity across renders.
708
+ """Return an [`AnimatedValue`][pythonnative.AnimatedValue] that is stable across renders.
666
709
 
667
710
  Convenience wrapper for the common pattern
668
711
  ``pn.use_memo(lambda: AnimatedValue(initial), [])``. The same
@@ -679,14 +722,15 @@ def use_animated_value(initial: float = 0.0) -> AnimatedValue:
679
722
  ```python
680
723
  import pythonnative as pn
681
724
 
725
+
682
726
  @pn.component
683
727
  def FadeIn():
684
728
  opacity = pn.use_animated_value(0.0)
685
729
 
686
- def fade_in():
687
- pn.Animated.timing(opacity, to=1.0, duration=300).start()
730
+ async def fade_in():
731
+ await pn.Animated.timing(opacity, to=1.0, duration=300)
688
732
 
689
- pn.use_effect(lambda: fade_in(), [])
733
+ pn.use_async_effect(fade_in, [])
690
734
  return pn.Animated.View(
691
735
  pn.Text("Hello"),
692
736
  style=pn.style(opacity=opacity),