pythonnative 0.25.0__py3-none-any.whl → 0.27.0__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.
Files changed (50) hide show
  1. pythonnative/__init__.py +1 -1
  2. pythonnative/animated.py +697 -62
  3. pythonnative/cli/pn.py +172 -12
  4. pythonnative/components.py +61 -7
  5. pythonnative/diagnostics.py +27 -0
  6. pythonnative/gestures.py +446 -34
  7. pythonnative/native_modules/app_state.py +2 -1
  8. pythonnative/native_modules/battery.py +3 -2
  9. pythonnative/native_modules/biometrics.py +2 -1
  10. pythonnative/native_modules/camera.py +16 -7
  11. pythonnative/native_modules/clipboard.py +3 -2
  12. pythonnative/native_modules/file_system.py +3 -2
  13. pythonnative/native_modules/haptics.py +7 -6
  14. pythonnative/native_modules/linking.py +62 -6
  15. pythonnative/native_modules/location.py +11 -8
  16. pythonnative/native_modules/net_info.py +75 -5
  17. pythonnative/native_modules/notifications.py +134 -15
  18. pythonnative/native_modules/permissions.py +3 -2
  19. pythonnative/native_modules/share.py +2 -1
  20. pythonnative/native_views/android.py +461 -137
  21. pythonnative/native_views/desktop.py +816 -96
  22. pythonnative/native_views/ios.py +750 -193
  23. pythonnative/project/android.py +15 -3
  24. pythonnative/project/builder.py +172 -70
  25. pythonnative/project/config.py +28 -6
  26. pythonnative/project/devices.py +291 -0
  27. pythonnative/project/doctor.py +5 -4
  28. pythonnative/project/ios.py +51 -77
  29. pythonnative/project/permissions.py +19 -1
  30. pythonnative/project/runtime_assets.py +78 -166
  31. pythonnative/reconciler.py +28 -0
  32. pythonnative/style.py +19 -0
  33. pythonnative/templates/android_template/app/build.gradle +9 -0
  34. pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +5 -0
  35. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +148 -12
  36. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNFrameLayout.kt +50 -0
  37. pythonnative/templates/ios_template/ios_template/AppDelegate.swift +41 -17
  38. pythonnative/templates/ios_template/ios_template/BridgingHeader.h +14 -0
  39. pythonnative/templates/ios_template/ios_template/Info.plist +5 -2
  40. pythonnative/templates/ios_template/ios_template/PythonRuntime.swift +397 -0
  41. pythonnative/templates/ios_template/ios_template/SceneDelegate.swift +31 -20
  42. pythonnative/templates/ios_template/ios_template/ViewController.swift +77 -198
  43. pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +72 -47
  44. {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/METADATA +3 -3
  45. {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/RECORD +49 -46
  46. pythonnative/templates/ios_template/ios_template/Base.lproj/Main.storyboard +0 -24
  47. {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/WHEEL +0 -0
  48. {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/entry_points.txt +0 -0
  49. {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/licenses/LICENSE +0 -0
  50. {pythonnative-0.25.0.dist-info → pythonnative-0.27.0.dist-info}/top_level.txt +0 -0
pythonnative/animated.py CHANGED
@@ -1,4 +1,4 @@
1
- """Animated values, native-driven animation, and animated components.
1
+ """Animated values, derived animated nodes, and native-driven animation.
2
2
 
3
3
  Modeled on React Native's ``Animated`` API with an ``async``-aware
4
4
  completion contract. The core primitives are:
@@ -6,14 +6,26 @@ completion contract. The core primitives are:
6
6
  - [`AnimatedValue`][pythonnative.animated.AnimatedValue]: a numeric
7
7
  cell attached to native view properties; animations drive it over
8
8
  time.
9
+ - **Derived nodes**: every animated node supports
10
+ [`interpolate`][pythonnative.animated.AnimatedNode.interpolate]
11
+ (range mapping with numeric, color, and angle outputs) and Python
12
+ arithmetic (``opacity * 0.5``, ``x + y``, ``-value``), producing
13
+ read-only [`AnimatedNode`][pythonnative.animated.AnimatedNode]
14
+ instances that update whenever their inputs change.
9
15
  - ``Animated.timing`` / ``Animated.spring`` / ``Animated.decay``:
10
16
  animation factories. The objects they return implement
11
17
  ``__await__``, so you can write ``await Animated.timing(v, to=1.0)``
12
18
  to suspend until the animation finishes.
13
- - ``Animated.sequence`` / ``Animated.parallel`` / ``Animated.delay``:
14
- composition; also awaitable.
19
+ - ``Animated.sequence`` / ``Animated.parallel`` / ``Animated.stagger``
20
+ / ``Animated.delay`` / ``Animated.loop``: composition; also
21
+ awaitable.
22
+ - ``Animated.event``: build an event-prop callback that copies event
23
+ fields into animated values (``on_scroll=pn.Animated.event(y=v)``).
24
+ - ``Animated.diff_clamp``: accumulate an input's *deltas* into a
25
+ clamped range (the collapsing-header primitive).
15
26
  - ``Animated.View`` / ``Animated.Text`` / ``Animated.Image``:
16
- components whose ``style`` may contain ``AnimatedValue`` instances.
27
+ components whose ``style`` may contain animated nodes, including
28
+ inside ``transform`` entries.
17
29
 
18
30
  Driver architecture (the **native driver**):
19
31
 
@@ -29,10 +41,15 @@ native view the value is attached to
29
41
  [`AnimatedValue`][pythonnative.animated.AnimatedValue], and resolves
30
42
  any awaiting tasks.
31
43
  - **Declined** (desktop preview, unattached values, callable easings,
32
- values feeding Python-side listeners): a single background thread
33
- ticks the animation at ~60 Hz from Python, pushing each frame through
34
- ``set_animated_property``. Semantics are identical; only the frame
35
- source differs.
44
+ values feeding Python-side listeners or derived nodes): a single
45
+ background thread ticks the animation at ~60 Hz from Python, pushing
46
+ each frame through ``set_animated_property``. Semantics are
47
+ identical; only the frame source differs.
48
+
49
+ Values driven by *events* (scroll offsets via ``Animated.event``,
50
+ gesture translations) flow through Python: the native listener fires,
51
+ the bound values update, and every attachment (including derived
52
+ nodes) is pushed in the same call.
36
53
 
37
54
  Example:
38
55
  ```python
@@ -59,11 +76,13 @@ Example:
59
76
  from __future__ import annotations
60
77
 
61
78
  import asyncio
79
+ import bisect
62
80
  import itertools
63
81
  import math
64
82
  import threading
65
83
  import time
66
- from typing import Any, Callable, Dict, List, Optional, Tuple
84
+ import weakref
85
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
67
86
 
68
87
  from .element import Element
69
88
  from .hooks import use_effect, use_ref
@@ -125,67 +144,93 @@ _anim_id_counter = itertools.count(1)
125
144
 
126
145
 
127
146
  # ======================================================================
128
- # AnimatedValue
147
+ # AnimatedNode: the shared graph-node base
129
148
  # ======================================================================
130
149
 
131
150
 
132
- class AnimatedValue:
133
- """A numeric cell that can be attached to native view properties.
151
+ class AnimatedNode:
152
+ """Base class for every animated node (settable leaves and derived nodes).
134
153
 
135
- Animated components (``Animated.View`` et al.) **attach** the value
136
- to ``(tag, prop)`` bindings after mount. Setting the value pushes
137
- the new number to every attached native view through the registry's
138
- ``set_animated_property``, and when an animation can be driven
139
- natively, the platform animates those same bindings directly.
154
+ An animated node holds a current output value and a set of
155
+ ``(tag, prop)`` **attachments** binding it to native view
156
+ properties. Whenever the node's output changes (a leaf was set or
157
+ animated, or an input of a derived node changed), the new value is
158
+ pushed to every attachment through the registry's
159
+ ``set_animated_property`` and to every Python-side listener, then
160
+ propagated to derived nodes built from this one.
140
161
 
141
- Python-side listeners registered via
142
- [`add_listener`][pythonnative.animated.AnimatedValue.add_listener]
143
- observe every Python-driven change. Natively-driven animations
144
- intentionally skip per-frame Python callbacks (that's the point);
145
- listeners see the final settled value.
162
+ Derived nodes are constructed with
163
+ [`interpolate`][pythonnative.animated.AnimatedNode.interpolate],
164
+ with Python arithmetic operators (``+``, ``-``, ``*``, ``/``,
165
+ ``%``, unary ``-``), or with ``Animated.diff_clamp``. They are
166
+ read-only: only [`AnimatedValue`][pythonnative.AnimatedValue]
167
+ leaves can be set or animated directly.
146
168
  """
147
169
 
148
- __slots__ = ("_value", "_subscribers", "_attachments", "_lock", "_native_group")
170
+ __slots__ = ("_subscribers", "_attachments", "_lock", "_children", "__weakref__")
149
171
 
150
- def __init__(self, initial: float = 0.0) -> None:
151
- self._value = float(initial)
152
- self._subscribers: List[Tuple[str, Callable[[float], None]]] = []
172
+ def __init__(self) -> None:
173
+ self._subscribers: List[Tuple[str, Callable[[Any], None]]] = []
153
174
  self._attachments: List[Tuple[int, str]] = []
154
175
  self._lock = threading.Lock()
155
- # The in-flight native animation group driving this value, if any.
156
- self._native_group: Optional["_NativeAnimationGroup"] = None
176
+ # Derived nodes built from this one. Weak so a discarded
177
+ # interpolation doesn't keep receiving pushes forever.
178
+ self._children: "weakref.WeakSet[AnimatedNode]" = weakref.WeakSet()
179
+
180
+ # -- value -----------------------------------------------------------
157
181
 
158
182
  @property
159
- def value(self) -> float:
160
- """Return the current numeric value (without subscribing)."""
161
- return self._value
183
+ def value(self) -> Any:
184
+ """Return the node's current output value."""
185
+ raise NotImplementedError
162
186
 
163
- def set_value(self, new_value: float) -> None:
164
- """Set the value immediately, pushing to native views and listeners."""
165
- self._apply(float(new_value), push_native=True)
187
+ def __float__(self) -> float:
188
+ try:
189
+ return float(self.value)
190
+ except (TypeError, ValueError):
191
+ return 0.0
166
192
 
167
- def _apply(self, new_value: float, push_native: bool) -> None:
193
+ # -- graph -----------------------------------------------------------
194
+
195
+ def _adopt_child(self, child: "AnimatedNode") -> None:
196
+ with self._lock:
197
+ self._children.add(child)
198
+
199
+ def _has_dependents(self) -> bool:
200
+ """Whether any derived node consumes this node's output."""
201
+ with self._lock:
202
+ return len(self._children) > 0
203
+
204
+ def _refresh(self) -> None:
205
+ """Hook for stateful derived nodes to update from their inputs."""
206
+
207
+ def _propagate(self) -> None:
208
+ """Push the current output to attachments/listeners and descend."""
209
+ self._refresh()
210
+ current = self.value
168
211
  with self._lock:
169
- self._value = new_value
170
212
  subs = list(self._subscribers)
171
213
  attachments = list(self._attachments)
172
- if push_native and attachments:
214
+ children = list(self._children)
215
+ if attachments:
173
216
  try:
174
217
  backend = _backend()
175
218
  for tag, prop in attachments:
176
- backend.set_animated_property(tag, prop, new_value)
219
+ backend.set_animated_property(tag, prop, current)
177
220
  except Exception:
178
221
  pass
179
- for prop, cb in subs:
222
+ for _prop, cb in subs:
180
223
  try:
181
- cb(new_value)
224
+ cb(current)
182
225
  except Exception:
183
226
  pass
227
+ for child in children:
228
+ child._propagate()
184
229
 
185
- # -- bindings ------------------------------------------------------
230
+ # -- bindings ----------------------------------------------------------
186
231
 
187
232
  def attach(self, tag: int, prop: str) -> Callable[[], None]:
188
- """Bind this value to ``prop`` of the native view under ``tag``.
233
+ """Bind this node to ``prop`` of the native view under ``tag``.
189
234
 
190
235
  The current value is pushed immediately so the view reflects it
191
236
  even if no animation is running. Returns a detach callable.
@@ -194,7 +239,7 @@ class AnimatedValue:
194
239
  with self._lock:
195
240
  self._attachments.append(binding)
196
241
  try:
197
- _backend().set_animated_property(tag, prop, self._value)
242
+ _backend().set_animated_property(tag, prop, self.value)
198
243
  except Exception:
199
244
  pass
200
245
 
@@ -212,14 +257,14 @@ class AnimatedValue:
212
257
  with self._lock:
213
258
  return list(self._attachments)
214
259
 
215
- # -- listeners -----------------------------------------------------
260
+ # -- listeners ---------------------------------------------------------
216
261
 
217
- def add_listener(self, prop: str, callback: Callable[[float], None]) -> Callable[[], None]:
218
- """Register ``callback`` for Python-driven changes to this value.
262
+ def add_listener(self, prop: str, callback: Callable[[Any], None]) -> Callable[[], None]:
263
+ """Register ``callback`` for Python-driven changes to this node.
219
264
 
220
265
  Returns an unsubscribe callable. ``prop`` is metadata only; it
221
266
  lets the subscriber differentiate this binding from others on
222
- the same ``AnimatedValue``.
267
+ the same node.
223
268
  """
224
269
  with self._lock:
225
270
  self._subscribers.append((prop, callback))
@@ -238,6 +283,149 @@ class AnimatedValue:
238
283
  with self._lock:
239
284
  return bool(self._subscribers)
240
285
 
286
+ # -- derivation --------------------------------------------------------
287
+
288
+ def interpolate(
289
+ self,
290
+ input_range: Sequence[float],
291
+ output_range: Sequence[Any],
292
+ extrapolate: str = "extend",
293
+ extrapolate_left: Optional[str] = None,
294
+ extrapolate_right: Optional[str] = None,
295
+ ) -> "AnimatedInterpolation":
296
+ """Map this node's value through an input/output range.
297
+
298
+ Mirrors React Native's ``interpolate``. ``output_range`` may
299
+ contain numbers, colors (``"#RRGGBB"`` / ``"#AARRGGBB"``), or
300
+ angle strings (``"45deg"`` / ``"0.5rad"``, emitted as numeric
301
+ degrees for the ``rotate`` transform).
302
+
303
+ Args:
304
+ input_range: Monotonically non-decreasing breakpoints for
305
+ this node's value. At least two entries.
306
+ output_range: Output breakpoints, same length as
307
+ ``input_range``.
308
+ extrapolate: Behavior outside the input range:
309
+ ``"extend"`` (continue the edge segment's slope,
310
+ default), ``"clamp"`` (pin to the edge output), or
311
+ ``"identity"`` (return the input unchanged).
312
+ extrapolate_left: Override ``extrapolate`` below the range.
313
+ extrapolate_right: Override ``extrapolate`` above the range.
314
+
315
+ Returns:
316
+ A derived, read-only animated node.
317
+
318
+ Example:
319
+ ```python
320
+ header_height = scroll_y.interpolate(
321
+ input_range=[0, 120],
322
+ output_range=[160, 56],
323
+ extrapolate="clamp",
324
+ )
325
+ ```
326
+ """
327
+ return AnimatedInterpolation(
328
+ self,
329
+ input_range,
330
+ output_range,
331
+ extrapolate=extrapolate,
332
+ extrapolate_left=extrapolate_left,
333
+ extrapolate_right=extrapolate_right,
334
+ )
335
+
336
+ # -- arithmetic --------------------------------------------------------
337
+
338
+ def __add__(self, other: Any) -> "_AnimatedOperation":
339
+ return _AnimatedOperation("add", lambda a, b: a + b, [self, other])
340
+
341
+ def __radd__(self, other: Any) -> "_AnimatedOperation":
342
+ return _AnimatedOperation("add", lambda a, b: a + b, [other, self])
343
+
344
+ def __sub__(self, other: Any) -> "_AnimatedOperation":
345
+ return _AnimatedOperation("subtract", lambda a, b: a - b, [self, other])
346
+
347
+ def __rsub__(self, other: Any) -> "_AnimatedOperation":
348
+ return _AnimatedOperation("subtract", lambda a, b: a - b, [other, self])
349
+
350
+ def __mul__(self, other: Any) -> "_AnimatedOperation":
351
+ return _AnimatedOperation("multiply", lambda a, b: a * b, [self, other])
352
+
353
+ def __rmul__(self, other: Any) -> "_AnimatedOperation":
354
+ return _AnimatedOperation("multiply", lambda a, b: a * b, [other, self])
355
+
356
+ def __truediv__(self, other: Any) -> "_AnimatedOperation":
357
+ return _AnimatedOperation("divide", lambda a, b: a / b if b else 0.0, [self, other])
358
+
359
+ def __rtruediv__(self, other: Any) -> "_AnimatedOperation":
360
+ return _AnimatedOperation("divide", lambda a, b: a / b if b else 0.0, [other, self])
361
+
362
+ def __mod__(self, other: Any) -> "_AnimatedOperation":
363
+ return _AnimatedOperation("modulo", lambda a, b: math.fmod(a, b) if b else 0.0, [self, other])
364
+
365
+ def __neg__(self) -> "_AnimatedOperation":
366
+ return _AnimatedOperation("negate", lambda a: -a, [self])
367
+
368
+
369
+ # ======================================================================
370
+ # AnimatedValue: the settable leaf
371
+ # ======================================================================
372
+
373
+
374
+ class AnimatedValue(AnimatedNode):
375
+ """A numeric cell that can be attached to native view properties.
376
+
377
+ Animated components (``Animated.View`` et al.) **attach** the value
378
+ to ``(tag, prop)`` bindings after mount. Setting the value pushes
379
+ the new number to every attached native view through the registry's
380
+ ``set_animated_property`` (and through every derived node built
381
+ from this value), and when an animation can be driven natively, the
382
+ platform animates those same bindings directly.
383
+
384
+ Python-side listeners registered via
385
+ [`add_listener`][pythonnative.animated.AnimatedNode.add_listener]
386
+ observe every Python-driven change. Natively-driven animations
387
+ intentionally skip per-frame Python callbacks (that's the point);
388
+ listeners see the final settled value.
389
+ """
390
+
391
+ __slots__ = ("_value", "_native_group")
392
+
393
+ def __init__(self, initial: float = 0.0) -> None:
394
+ super().__init__()
395
+ self._value = float(initial)
396
+ # The in-flight native animation group driving this value, if any.
397
+ self._native_group: Optional["_NativeAnimationGroup"] = None
398
+
399
+ @property
400
+ def value(self) -> float:
401
+ """Return the current numeric value (without subscribing)."""
402
+ return self._value
403
+
404
+ def set_value(self, new_value: float) -> None:
405
+ """Set the value immediately, pushing to native views and listeners."""
406
+ self._apply(float(new_value), push_native=True)
407
+
408
+ def _apply(self, new_value: float, push_native: bool) -> None:
409
+ with self._lock:
410
+ self._value = new_value
411
+ subs = list(self._subscribers)
412
+ attachments = list(self._attachments)
413
+ children = list(self._children)
414
+ if push_native and attachments:
415
+ try:
416
+ backend = _backend()
417
+ for tag, prop in attachments:
418
+ backend.set_animated_property(tag, prop, new_value)
419
+ except Exception:
420
+ pass
421
+ for prop, cb in subs:
422
+ try:
423
+ cb(new_value)
424
+ except Exception:
425
+ pass
426
+ for child in children:
427
+ child._propagate()
428
+
241
429
  # -- native handoff ------------------------------------------------
242
430
 
243
431
  def _adopt_native_group(self, group: Optional["_NativeAnimationGroup"]) -> None:
@@ -251,13 +439,272 @@ class AnimatedValue:
251
439
  self._adopt_native_group(None)
252
440
  _manager.cancel_for_value(self)
253
441
 
254
- def __float__(self) -> float:
255
- return self._value
256
-
257
442
  def __repr__(self) -> str:
258
443
  return f"AnimatedValue({self._value:g})"
259
444
 
260
445
 
446
+ # ======================================================================
447
+ # Derived nodes
448
+ # ======================================================================
449
+
450
+
451
+ def _parse_color_output(value: str) -> Optional[Tuple[int, int, int, int]]:
452
+ """Parse ``"#RRGGBB"`` / ``"#AARRGGBB"`` into an ``(a, r, g, b)`` tuple."""
453
+ c = value.strip().lstrip("#")
454
+ if len(c) == 6:
455
+ c = "FF" + c
456
+ if len(c) != 8:
457
+ return None
458
+ try:
459
+ raw = int(c, 16)
460
+ except ValueError:
461
+ return None
462
+ return ((raw >> 24) & 0xFF, (raw >> 16) & 0xFF, (raw >> 8) & 0xFF, raw & 0xFF)
463
+
464
+
465
+ def _parse_angle_output(value: str) -> Optional[float]:
466
+ """Parse ``"45deg"`` / ``"0.5rad"`` into numeric degrees."""
467
+ text = value.strip()
468
+ try:
469
+ if text.endswith("deg"):
470
+ return float(text[:-3])
471
+ if text.endswith("rad"):
472
+ return math.degrees(float(text[:-3]))
473
+ except ValueError:
474
+ return None
475
+ return None
476
+
477
+
478
+ class AnimatedInterpolation(AnimatedNode):
479
+ """Read-only node mapping a parent node through an input/output range.
480
+
481
+ Built via
482
+ [`AnimatedNode.interpolate`][pythonnative.animated.AnimatedNode.interpolate];
483
+ see that method for the semantics of the arguments.
484
+ """
485
+
486
+ __slots__ = (
487
+ "_parent",
488
+ "_inputs",
489
+ "_outputs",
490
+ "_kind",
491
+ "_left",
492
+ "_right",
493
+ )
494
+
495
+ def __init__(
496
+ self,
497
+ parent: AnimatedNode,
498
+ input_range: Sequence[float],
499
+ output_range: Sequence[Any],
500
+ extrapolate: str = "extend",
501
+ extrapolate_left: Optional[str] = None,
502
+ extrapolate_right: Optional[str] = None,
503
+ ) -> None:
504
+ super().__init__()
505
+ inputs = [float(v) for v in input_range]
506
+ outputs = list(output_range)
507
+ if len(inputs) < 2:
508
+ raise ValueError("interpolate() needs at least two input_range entries")
509
+ if len(inputs) != len(outputs):
510
+ raise ValueError("interpolate() input_range and output_range must have the same length")
511
+ for a, b in zip(inputs, inputs[1:]):
512
+ if b < a:
513
+ raise ValueError("interpolate() input_range must be monotonically non-decreasing")
514
+
515
+ kind = "number"
516
+ first = outputs[0]
517
+ if isinstance(first, str):
518
+ if _parse_color_output(first) is not None:
519
+ kind = "color"
520
+ outputs = [_parse_color_output(str(v)) for v in outputs]
521
+ if any(v is None for v in outputs):
522
+ raise ValueError("interpolate() color output_range entries must all be colors")
523
+ else:
524
+ angles = [_parse_angle_output(str(v)) for v in outputs]
525
+ if any(v is None for v in angles):
526
+ raise ValueError(f"interpolate() cannot parse output value {first!r}")
527
+ outputs = angles
528
+ else:
529
+ outputs = [float(v) for v in outputs]
530
+
531
+ self._parent = parent
532
+ self._inputs = inputs
533
+ self._outputs: List[Any] = outputs
534
+ self._kind = kind
535
+ self._left = extrapolate_left or extrapolate
536
+ self._right = extrapolate_right or extrapolate
537
+ parent._adopt_child(self)
538
+
539
+ @property
540
+ def value(self) -> Any:
541
+ """Return the interpolated output for the parent's current value."""
542
+ return self._compute(float(self._parent))
543
+
544
+ def _compute(self, x: float) -> Any:
545
+ inputs = self._inputs
546
+ n = len(inputs)
547
+ if x < inputs[0]:
548
+ if self._left == "identity":
549
+ return x
550
+ if self._left == "clamp":
551
+ x = inputs[0]
552
+ i = 0
553
+ elif x > inputs[-1]:
554
+ if self._right == "identity":
555
+ return x
556
+ if self._right == "clamp":
557
+ x = inputs[-1]
558
+ i = n - 2
559
+ else:
560
+ i = max(0, min(n - 2, bisect.bisect_right(inputs, x) - 1))
561
+
562
+ x0, x1 = inputs[i], inputs[i + 1]
563
+ span = x1 - x0
564
+ t = 0.0 if span <= 0 else (x - x0) / span
565
+
566
+ if self._kind == "color":
567
+ c0 = self._outputs[i]
568
+ c1 = self._outputs[i + 1]
569
+ t_cl = max(0.0, min(1.0, t))
570
+ channels = [int(round(c0[j] + (c1[j] - c0[j]) * t_cl)) for j in range(4)]
571
+ a, r, g, b = (max(0, min(255, ch)) for ch in channels)
572
+ return f"#{a:02X}{r:02X}{g:02X}{b:02X}"
573
+
574
+ y0 = self._outputs[i]
575
+ y1 = self._outputs[i + 1]
576
+ return y0 + (y1 - y0) * t
577
+
578
+ def __repr__(self) -> str:
579
+ return f"AnimatedInterpolation({self._inputs} -> {self._outputs})"
580
+
581
+
582
+ class _AnimatedOperation(AnimatedNode):
583
+ """Read-only node computed from other nodes (and constants) by ``fn``."""
584
+
585
+ __slots__ = ("_op", "_fn", "_parents")
586
+
587
+ def __init__(self, op: str, fn: Callable[..., float], parents: List[Any]) -> None:
588
+ super().__init__()
589
+ self._op = op
590
+ self._fn = fn
591
+ self._parents = list(parents)
592
+ for parent in self._parents:
593
+ if isinstance(parent, AnimatedNode):
594
+ parent._adopt_child(self)
595
+
596
+ @property
597
+ def value(self) -> float:
598
+ args = [float(p) if isinstance(p, AnimatedNode) else float(p) for p in self._parents]
599
+ try:
600
+ return float(self._fn(*args))
601
+ except Exception:
602
+ return 0.0
603
+
604
+ def __repr__(self) -> str:
605
+ return f"AnimatedOperation({self._op})"
606
+
607
+
608
+ class _AnimatedDiffClamp(AnimatedNode):
609
+ """Accumulate a parent's *deltas* into a clamped range.
610
+
611
+ Mirrors React Native's ``Animated.diffClamp``: the output moves by
612
+ the same amount as the input but is pinned to ``[min, max]``, so
613
+ scrolling far down then slightly up immediately re-reveals a
614
+ collapsing header regardless of absolute offset.
615
+ """
616
+
617
+ __slots__ = ("_parent", "_min", "_max", "_last_input", "_current")
618
+
619
+ def __init__(self, parent: AnimatedNode, min_value: float, max_value: float) -> None:
620
+ super().__init__()
621
+ if max_value < min_value:
622
+ raise ValueError("diff_clamp() requires min_value <= max_value")
623
+ self._parent = parent
624
+ self._min = float(min_value)
625
+ self._max = float(max_value)
626
+ self._last_input = float(parent)
627
+ self._current = max(self._min, min(self._max, self._last_input))
628
+ parent._adopt_child(self)
629
+
630
+ @property
631
+ def value(self) -> float:
632
+ return self._current
633
+
634
+ def _refresh(self) -> None:
635
+ latest = float(self._parent)
636
+ delta = latest - self._last_input
637
+ self._last_input = latest
638
+ self._current = max(self._min, min(self._max, self._current + delta))
639
+
640
+
641
+ # ======================================================================
642
+ # Animated.event
643
+ # ======================================================================
644
+
645
+
646
+ class AnimatedEvent:
647
+ """Callable event handler copying event fields into animated values.
648
+
649
+ Built via ``pn.Animated.event(...)``. Each keyword argument names a
650
+ field on the incoming event payload (a dict key for scroll payloads
651
+ such as ``{"x": ..., "y": ...}``, or an attribute for
652
+ [`GestureEvent`][pythonnative.gestures.GestureEvent] instances) and
653
+ maps it onto an [`AnimatedValue`][pythonnative.AnimatedValue].
654
+
655
+ Because the result is an ordinary callable, it can be passed to any
656
+ event prop:
657
+
658
+ ```python
659
+ scroll_y = pn.use_animated_value(0.0)
660
+ pn.ScrollView(..., on_scroll=pn.Animated.event(y=scroll_y))
661
+
662
+ tx = pn.use_animated_value(0.0)
663
+ gestures.Pan(on_change=pn.Animated.event(translation_x=tx))
664
+ ```
665
+ """
666
+
667
+ __slots__ = ("_bindings", "_listener")
668
+
669
+ def __init__(self, listener: Optional[Callable[..., None]] = None, **bindings: AnimatedValue) -> None:
670
+ for name, node in bindings.items():
671
+ if not isinstance(node, AnimatedValue):
672
+ raise TypeError(
673
+ f"Animated.event() field {name!r} must map to an AnimatedValue "
674
+ f"(got {type(node).__name__}); derived nodes are read-only."
675
+ )
676
+ self._bindings = dict(bindings)
677
+ self._listener = listener
678
+
679
+ def __call__(self, payload: Any = None, *args: Any) -> None:
680
+ """Write bound payload fields into their values, then run the listener.
681
+
682
+ Args:
683
+ payload: The event payload; a dict is read by key, any
684
+ other object by attribute. Missing or non-numeric
685
+ fields are skipped.
686
+ *args: Extra positional arguments forwarded to the
687
+ listener.
688
+ """
689
+ for name, node in self._bindings.items():
690
+ raw: Any = None
691
+ if isinstance(payload, dict):
692
+ raw = payload.get(name)
693
+ elif payload is not None:
694
+ raw = getattr(payload, name, None)
695
+ if raw is None:
696
+ continue
697
+ try:
698
+ node.set_value(float(raw))
699
+ except (TypeError, ValueError):
700
+ continue
701
+ if self._listener is not None:
702
+ try:
703
+ self._listener(payload, *args)
704
+ except Exception:
705
+ pass
706
+
707
+
261
708
  # ======================================================================
262
709
  # Python fallback driver
263
710
  # ======================================================================
@@ -601,6 +1048,10 @@ def _start_native(value: AnimatedValue, spec: Dict[str, Any]) -> Optional[_Nativ
601
1048
  # Python listeners want per-frame values; only the ticker
602
1049
  # provides those.
603
1050
  return None
1051
+ if value._has_dependents():
1052
+ # Derived nodes (interpolations, arithmetic) need per-frame
1053
+ # Python evaluation to keep their own attachments in sync.
1054
+ return None
604
1055
  try:
605
1056
  backend = _backend()
606
1057
  except Exception:
@@ -733,8 +1184,19 @@ class _AnimationHandle(_AwaitableAnimation):
733
1184
  anim._finish()
734
1185
  _manager.remove(anim)
735
1186
 
1187
+ def _is_running(self) -> bool:
1188
+ if self._native_group is not None and not self._native_group._completed:
1189
+ return True
1190
+ if self._python_anim is not None and not self._python_anim._completed:
1191
+ return True
1192
+ return False
1193
+
736
1194
  async def _drive(self) -> None:
737
- if self._native_group is None and self._python_anim is None:
1195
+ # (Re)start unless an instance is currently mid-flight, so a
1196
+ # reused handle (``Animated.loop``, awaiting the same handle
1197
+ # twice) runs a fresh animation instead of resolving instantly
1198
+ # against the finished previous instance.
1199
+ if not self._is_running():
738
1200
  self.start()
739
1201
  loop = asyncio.get_running_loop()
740
1202
  future: asyncio.Future[None] = loop.create_future()
@@ -748,11 +1210,12 @@ class _AnimationHandle(_AwaitableAnimation):
748
1210
 
749
1211
 
750
1212
  class _CompositeAnimation(_AwaitableAnimation):
751
- """Run a list of animations in sequence or in parallel."""
1213
+ """Run a list of animations in sequence, in parallel, or staggered."""
752
1214
 
753
- def __init__(self, items: List[Any], mode: str) -> None:
1215
+ def __init__(self, items: List[Any], mode: str, stagger_ms: float = 0.0) -> None:
754
1216
  self._items = list(items)
755
1217
  self._mode = mode
1218
+ self._stagger_ms = float(stagger_ms)
756
1219
 
757
1220
  def start(self) -> "_CompositeAnimation":
758
1221
  """Schedule the composite on the framework runtime, fire-and-forget."""
@@ -772,6 +1235,16 @@ class _CompositeAnimation(_AwaitableAnimation):
772
1235
  if self._mode == "parallel":
773
1236
  await asyncio.gather(*(self._await_item(item) for item in self._items))
774
1237
  return
1238
+ if self._mode == "stagger":
1239
+ delay_s = max(0.0, self._stagger_ms) / 1000.0
1240
+
1241
+ async def _delayed(index: int, item: Any) -> None:
1242
+ if index > 0 and delay_s > 0.0:
1243
+ await asyncio.sleep(delay_s * index)
1244
+ await self._await_item(item)
1245
+
1246
+ await asyncio.gather(*(_delayed(i, item) for i, item in enumerate(self._items)))
1247
+ return
775
1248
  for item in self._items:
776
1249
  await self._await_item(item)
777
1250
 
@@ -784,26 +1257,95 @@ class _CompositeAnimation(_AwaitableAnimation):
784
1257
  await item
785
1258
 
786
1259
 
1260
+ class _LoopAnimation(_AwaitableAnimation):
1261
+ """Repeat an animation, resetting its values before each iteration."""
1262
+
1263
+ def __init__(self, animation: Any, iterations: int = -1, reset: bool = True) -> None:
1264
+ self._animation = animation
1265
+ self._iterations = int(iterations)
1266
+ self._reset = bool(reset)
1267
+ self._stopped = False
1268
+
1269
+ def start(self) -> "_LoopAnimation":
1270
+ from .runtime import run_async
1271
+
1272
+ self._stopped = False
1273
+ run_async(self._drive())
1274
+ return self
1275
+
1276
+ def stop(self) -> None:
1277
+ self._stopped = True
1278
+ try:
1279
+ self._animation.stop()
1280
+ except Exception:
1281
+ pass
1282
+
1283
+ def _collect_values(self, item: Any, out: List[AnimatedValue]) -> None:
1284
+ if isinstance(item, _AnimationHandle):
1285
+ if item._value is not None and item._value not in out:
1286
+ out.append(item._value)
1287
+ elif isinstance(item, _CompositeAnimation):
1288
+ for sub in item._items:
1289
+ self._collect_values(sub, out)
1290
+ elif isinstance(item, _LoopAnimation):
1291
+ self._collect_values(item._animation, out)
1292
+
1293
+ async def _drive(self) -> None:
1294
+ self._stopped = False
1295
+ values: List[AnimatedValue] = []
1296
+ self._collect_values(self._animation, values)
1297
+ origins = [(v, v.value) for v in values]
1298
+ count = 0
1299
+ while not self._stopped and (self._iterations < 0 or count < self._iterations):
1300
+ if self._reset and count > 0:
1301
+ for value, origin in origins:
1302
+ value.set_value(origin)
1303
+ await self._animation
1304
+ count += 1
1305
+
1306
+
787
1307
  # ======================================================================
788
1308
  # Animated component wrappers
789
1309
  # ======================================================================
790
1310
 
1311
+ # Transform-entry keys that may carry animated nodes; the key doubles
1312
+ # as the ``set_animated_property`` prop name.
1313
+ _ANIMATED_TRANSFORM_KEYS = frozenset(
1314
+ {"translate_x", "translate_y", "scale", "scale_x", "scale_y", "rotate"},
1315
+ )
791
1316
 
792
- def _resolve_style_with_values(style: StyleProp) -> Tuple[Dict[str, Any], Dict[str, AnimatedValue]]:
1317
+
1318
+ def _resolve_style_with_values(style: StyleProp) -> Tuple[Dict[str, Any], Dict[str, AnimatedNode]]:
793
1319
  """Split ``style`` into a plain dict and animated bindings.
794
1320
 
795
- AnimatedValue entries in the style are replaced with their current
796
- numeric value in ``plain_style`` and recorded in
797
- ``animated_bindings`` so the wrapping component can attach them
798
- after mount.
1321
+ Animated nodes in the style (top-level values *and* values inside
1322
+ ``transform`` entries) are replaced with their current numeric
1323
+ value in ``plain_style`` and recorded in ``animated_bindings`` so
1324
+ the wrapping component can attach them after mount.
799
1325
  """
800
1326
  flat = resolve_style(style)
801
- bindings: Dict[str, AnimatedValue] = {}
1327
+ bindings: Dict[str, AnimatedNode] = {}
802
1328
  plain: Dict[str, Any] = {}
803
1329
  for k, v in flat.items():
804
- if isinstance(v, AnimatedValue):
1330
+ if isinstance(v, AnimatedNode):
805
1331
  bindings[k] = v
806
1332
  plain[k] = v.value
1333
+ elif k == "transform" and v is not None:
1334
+ entries = v if isinstance(v, list) else [v]
1335
+ plain_entries: List[Any] = []
1336
+ for entry in entries:
1337
+ if not isinstance(entry, dict):
1338
+ plain_entries.append(entry)
1339
+ continue
1340
+ clean_entry: Dict[str, Any] = {}
1341
+ for prop, val in entry.items():
1342
+ if isinstance(val, AnimatedNode) and prop in _ANIMATED_TRANSFORM_KEYS:
1343
+ bindings[prop] = val
1344
+ clean_entry[prop] = val.value
1345
+ else:
1346
+ clean_entry[prop] = val
1347
+ plain_entries.append(clean_entry)
1348
+ plain[k] = plain_entries
807
1349
  else:
808
1350
  plain[k] = v
809
1351
  return plain, bindings
@@ -877,8 +1419,9 @@ def _animated_prop_name(prop: str) -> str:
877
1419
  class _AnimatedNamespace:
878
1420
  """Public ``Animated`` namespace.
879
1421
 
880
- Exposes the ``Value`` type, animation factories, composers, and
881
- component wrappers (``View``, ``Text``, ``Image``).
1422
+ Exposes the ``Value`` type, animation factories, composers,
1423
+ derived-node helpers (``event``, ``diff_clamp``), and component
1424
+ wrappers (``View``, ``Text``, ``Image``).
882
1425
  """
883
1426
 
884
1427
  Value = AnimatedValue
@@ -968,6 +1511,54 @@ class _AnimatedNamespace:
968
1511
  """Run ``animations`` one after another."""
969
1512
  return _CompositeAnimation(animations, "sequence")
970
1513
 
1514
+ @staticmethod
1515
+ def stagger(delay: float, animations: List[Any]) -> _CompositeAnimation:
1516
+ """Run ``animations`` in parallel, each starting ``delay`` ms after the previous.
1517
+
1518
+ Args:
1519
+ delay: Milliseconds between successive starts.
1520
+ animations: Animation handles (or awaitables) to run.
1521
+
1522
+ Example:
1523
+ ```python
1524
+ pn.Animated.stagger(80, [
1525
+ pn.Animated.timing(v, to=1.0) for v in card_opacities
1526
+ ]).start()
1527
+ ```
1528
+ """
1529
+ return _CompositeAnimation(animations, "stagger", stagger_ms=delay)
1530
+
1531
+ @staticmethod
1532
+ def loop(animation: Any, *, iterations: int = -1, reset: bool = True) -> _LoopAnimation:
1533
+ """Repeat ``animation``, optionally forever.
1534
+
1535
+ Values driven by the animation are captured when the loop
1536
+ starts and restored before each iteration (matching React
1537
+ Native's ``resetBeforeIteration``), so ``timing`` loops replay
1538
+ the same motion instead of animating in place.
1539
+
1540
+ Args:
1541
+ animation: A handle from ``timing`` / ``spring`` / ``decay``
1542
+ or a ``sequence`` / ``parallel`` / ``stagger`` composite.
1543
+ iterations: Number of repetitions; ``-1`` (default) loops
1544
+ until [`stop`][pythonnative.animated._LoopAnimation.stop]
1545
+ is called or the awaiting task is cancelled.
1546
+ reset: When ``False``, values continue from wherever the
1547
+ previous iteration ended.
1548
+
1549
+ Example:
1550
+ ```python
1551
+ pulse = pn.Animated.loop(
1552
+ pn.Animated.sequence([
1553
+ pn.Animated.timing(scale, to=1.15, duration=350),
1554
+ pn.Animated.timing(scale, to=1.0, duration=350),
1555
+ ]),
1556
+ ).start()
1557
+ # later: pulse.stop()
1558
+ ```
1559
+ """
1560
+ return _LoopAnimation(animation, iterations=iterations, reset=reset)
1561
+
971
1562
  @staticmethod
972
1563
  def delay(duration: float) -> _AnimationHandle:
973
1564
  """Wait ``duration`` ms before continuing in a sequence."""
@@ -980,6 +1571,47 @@ class _AnimatedNamespace:
980
1571
 
981
1572
  return _AnimationHandle(None, _spec, _fallback)
982
1573
 
1574
+ @staticmethod
1575
+ def event(listener: Optional[Callable[..., None]] = None, **bindings: AnimatedValue) -> AnimatedEvent:
1576
+ """Build a callback that copies event fields into animated values.
1577
+
1578
+ Pass the result to any event prop. Each keyword maps a payload
1579
+ field (dict key or dataclass attribute) onto an
1580
+ [`AnimatedValue`][pythonnative.AnimatedValue]:
1581
+
1582
+ ```python
1583
+ scroll_y = pn.use_animated_value(0.0)
1584
+ pn.ScrollView(..., on_scroll=pn.Animated.event(y=scroll_y))
1585
+
1586
+ tx = pn.use_animated_value(0.0)
1587
+ gestures.Pan(on_change=pn.Animated.event(translation_x=tx))
1588
+ ```
1589
+
1590
+ Args:
1591
+ listener: Optional plain callback invoked with the raw
1592
+ event after the values update.
1593
+ **bindings: ``field_name=animated_value`` pairs.
1594
+
1595
+ Returns:
1596
+ A callable [`AnimatedEvent`][pythonnative.animated.AnimatedEvent].
1597
+ """
1598
+ return AnimatedEvent(listener, **bindings)
1599
+
1600
+ @staticmethod
1601
+ def diff_clamp(node: AnimatedNode, min_value: float, max_value: float) -> AnimatedNode:
1602
+ """Accumulate ``node``'s deltas into ``[min_value, max_value]``.
1603
+
1604
+ The classic collapsing-header primitive: unlike ``interpolate``
1605
+ with ``"clamp"`` (which pins the *absolute* input), the output
1606
+ tracks input *movement*, so a small scroll upward immediately
1607
+ re-reveals the header no matter how far down the list is.
1608
+
1609
+ ```python
1610
+ header_shift = pn.Animated.diff_clamp(scroll_y, 0, 56)
1611
+ ```
1612
+ """
1613
+ return _AnimatedDiffClamp(node, min_value, max_value)
1614
+
983
1615
  View = staticmethod(_make_animated_factory("View", accept_children=True))
984
1616
  Text = staticmethod(_make_animated_factory("Text", accept_children=False))
985
1617
  Image = staticmethod(_make_animated_factory("Image", accept_children=False))
@@ -1027,7 +1659,10 @@ def use_animated_value(initial: float = 0.0) -> AnimatedValue:
1027
1659
 
1028
1660
 
1029
1661
  __all__ = [
1662
+ "AnimatedNode",
1030
1663
  "AnimatedValue",
1664
+ "AnimatedInterpolation",
1665
+ "AnimatedEvent",
1031
1666
  "Animated",
1032
1667
  "use_animated_value",
1033
1668
  "native_animation_completed",