pythonnative 0.26.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.
pythonnative/gestures.py CHANGED
@@ -1,4 +1,4 @@
1
- """Native-backed gesture system.
1
+ """Native-backed gesture system with composition and arbitration.
2
2
 
3
3
  Attach gestures to any view-like element via the ``gestures=`` prop:
4
4
 
@@ -12,10 +12,6 @@ def Draggable():
12
12
  tx = pn.use_animated_value(0.0)
13
13
  ty = pn.use_animated_value(0.0)
14
14
 
15
- def on_pan(event):
16
- tx.set_value(event.translation_x)
17
- ty.set_value(event.translation_y)
18
-
19
15
  def on_end(event):
20
16
  pn.Animated.spring(tx, to=0.0).start()
21
17
  pn.Animated.spring(ty, to=0.0).start()
@@ -23,7 +19,12 @@ def Draggable():
23
19
  return pn.Animated.View(
24
20
  pn.Text("Drag me"),
25
21
  style={"transform": [{"translate_x": tx}, {"translate_y": ty}], "padding": 24},
26
- gestures=[gestures.Pan(on_change=on_pan, on_end=on_end)],
22
+ gestures=[
23
+ gestures.Pan(
24
+ on_change=pn.Animated.event(translation_x=tx, translation_y=ty),
25
+ on_end=on_end,
26
+ )
27
+ ],
27
28
  )
28
29
  ```
29
30
 
@@ -38,8 +39,32 @@ tag-based event channel. Recognition itself is native:
38
39
  [`GestureArbiter`][pythonnative.gestures.GestureArbiter] below.
39
40
  - **Desktop** feeds Tk pointer events into the same arbiter.
40
41
 
41
- All gestures attached to one view recognize *simultaneously*; there is
42
- no cross-gesture exclusivity arbitration yet.
42
+ Composition
43
+ -----------
44
+
45
+ Gestures in a plain ``gestures=[...]`` list all recognize
46
+ *simultaneously* (a press ripple plus a pan plus a pinch can all run at
47
+ once). To control how gestures interact, wrap them in composition
48
+ nodes, which nest arbitrarily:
49
+
50
+ - [`Simultaneous`][pythonnative.gestures.Simultaneous]: members may all
51
+ activate together (the flat-list default, useful inside other nodes).
52
+ - [`Race`][pythonnative.gestures.Race]: the first member to activate
53
+ wins; the rest fail for the remainder of the interaction.
54
+ - [`Exclusive`][pythonnative.gestures.Exclusive]: priority order. A
55
+ member may only activate after every member listed *before* it has
56
+ failed. ``Exclusive(double_tap, single_tap)`` is the classic
57
+ double-tap-wins arrangement: the single tap fires only after the
58
+ double-tap window expires.
59
+
60
+ ```python
61
+ gestures=[
62
+ gestures.Race(
63
+ gestures.Pan(on_change=drag),
64
+ gestures.LongPress(on_long_press=show_menu),
65
+ ),
66
+ ]
67
+ ```
43
68
 
44
69
  Every callback receives a [`GestureEvent`][pythonnative.gestures.GestureEvent]
45
70
  with position, translation, velocity, scale, and rotation populated as
@@ -49,8 +74,8 @@ appropriate for the gesture kind.
49
74
  from __future__ import annotations
50
75
 
51
76
  import math
52
- from dataclasses import dataclass
53
- from typing import Any, Callable, Dict, List, Literal, Optional, Sequence, Tuple
77
+ from dataclasses import dataclass, field
78
+ from typing import Any, Callable, Dict, List, Literal, Optional, Sequence, Set, Tuple
54
79
 
55
80
  __all__ = [
56
81
  "GestureState",
@@ -59,8 +84,13 @@ __all__ = [
59
84
  "LongPress",
60
85
  "Pan",
61
86
  "Swipe",
87
+ "Fling",
62
88
  "Pinch",
63
89
  "Rotation",
90
+ "Simultaneous",
91
+ "Race",
92
+ "Exclusive",
93
+ "GestureGroup",
64
94
  "GestureArbiter",
65
95
  "serialize_gestures",
66
96
  ]
@@ -88,7 +118,7 @@ class GestureEvent:
88
118
 
89
119
  Attributes:
90
120
  kind: Gesture kind (``"tap"``, ``"long_press"``, ``"pan"``,
91
- ``"swipe"``, ``"pinch"``, ``"rotation"``).
121
+ ``"swipe"``, ``"fling"``, ``"pinch"``, ``"rotation"``).
92
122
  state: One of [`GestureState`][pythonnative.gestures.GestureState].
93
123
  x: Pointer x-position in the view's coordinate space (points).
94
124
  y: Pointer y-position in the view's coordinate space (points).
@@ -97,14 +127,14 @@ class GestureEvent:
97
127
  translation_y: Vertical displacement since the gesture
98
128
  activated (pan only).
99
129
  velocity_x: Horizontal pointer velocity in points/second
100
- (pan and swipe).
130
+ (pan, swipe, and fling).
101
131
  velocity_y: Vertical pointer velocity in points/second
102
- (pan and swipe).
132
+ (pan, swipe, and fling).
103
133
  scale: Pinch scale factor relative to activation (pinch only).
104
134
  rotation: Rotation in radians relative to activation
105
135
  (rotation only).
106
136
  pointer_count: Number of pointers currently down.
107
- direction: Resolved swipe direction (swipe only).
137
+ direction: Resolved swipe/fling direction.
108
138
  """
109
139
 
110
140
  kind: str
@@ -291,6 +321,48 @@ class Swipe(_BaseGesture):
291
321
  super()._dispatch(event)
292
322
 
293
323
 
324
+ @dataclass(frozen=True)
325
+ class Fling(_BaseGesture):
326
+ """Recognize a quick multi-pointer directional flick.
327
+
328
+ Like [`Swipe`][pythonnative.gestures.Swipe] but with a pointer-count
329
+ requirement, mirroring React Native Gesture Handler's ``Fling``
330
+ (and iOS ``UISwipeGestureRecognizer`` with
331
+ ``numberOfTouchesRequired``). A two-finger downward fling is a
332
+ common dismiss gesture:
333
+
334
+ ```python
335
+ gestures.Fling(direction="down", n_pointers=2, on_fling=dismiss)
336
+ ```
337
+
338
+ Attributes:
339
+ on_fling: Called once on release with the resolved
340
+ ``direction`` and release velocity.
341
+ direction: Required direction, or ``"any"``.
342
+ n_pointers: Number of pointers that must participate.
343
+ min_velocity: Minimum release speed in points/second.
344
+ """
345
+
346
+ on_fling: Optional[GestureCallback] = None
347
+ direction: SwipeDirection = "any"
348
+ n_pointers: int = 1
349
+ min_velocity: float = 300.0
350
+ kind: str = "fling"
351
+
352
+ def _config(self) -> Dict[str, Any]:
353
+ return {
354
+ "direction": str(self.direction),
355
+ "n_pointers": int(self.n_pointers),
356
+ "min_velocity": float(self.min_velocity),
357
+ }
358
+
359
+ def _dispatch(self, event: GestureEvent) -> None:
360
+ if event.state == GestureState.ENDED and self.on_fling is not None:
361
+ self.on_fling(event)
362
+ else:
363
+ super()._dispatch(event)
364
+
365
+
294
366
  @dataclass(frozen=True)
295
367
  class Pinch(_BaseGesture):
296
368
  """Track a two-finger pinch; ``event.scale`` is relative to activation."""
@@ -309,34 +381,139 @@ GestureSpec = _BaseGesture
309
381
  """Any gesture descriptor accepted by the ``gestures=`` prop."""
310
382
 
311
383
 
384
+ # ======================================================================
385
+ # Composition nodes
386
+ # ======================================================================
387
+
388
+
389
+ @dataclass(frozen=True)
390
+ class GestureGroup:
391
+ """A composition node relating child gestures (or nested groups).
392
+
393
+ Build instances with [`Simultaneous`][pythonnative.gestures.Simultaneous],
394
+ [`Race`][pythonnative.gestures.Race], or
395
+ [`Exclusive`][pythonnative.gestures.Exclusive] rather than directly.
396
+ """
397
+
398
+ mode: Literal["simultaneous", "race", "exclusive"]
399
+ children: Tuple[Any, ...] = field(default_factory=tuple)
400
+
401
+
402
+ def Simultaneous(*gestures: Any) -> GestureGroup:
403
+ """Compose gestures that may all be active at the same time.
404
+
405
+ This matches the flat-list default; it exists so simultaneity can
406
+ be expressed *inside* [`Race`][pythonnative.gestures.Race] or
407
+ [`Exclusive`][pythonnative.gestures.Exclusive] nodes:
408
+
409
+ ```python
410
+ gestures.Race(
411
+ gestures.Simultaneous(gestures.Pinch(...), gestures.Rotation(...)),
412
+ gestures.Pan(...),
413
+ )
414
+ ```
415
+ """
416
+ return GestureGroup("simultaneous", tuple(gestures))
417
+
418
+
419
+ def Race(*gestures: Any) -> GestureGroup:
420
+ """Compose gestures where only the first to activate wins.
421
+
422
+ As soon as one member activates, every other member fails for the
423
+ rest of the interaction (its in-progress recognition is abandoned
424
+ without firing callbacks).
425
+ """
426
+ return GestureGroup("race", tuple(gestures))
427
+
428
+
429
+ def Exclusive(*gestures: Any) -> GestureGroup:
430
+ """Compose gestures by priority: earlier members outrank later ones.
431
+
432
+ A member may only activate once every member listed before it has
433
+ *failed*. ``Exclusive(double_tap, single_tap)`` delays the single
434
+ tap until the double-tap window has expired, then fires it; if the
435
+ second tap lands in time, only the double tap fires.
436
+ """
437
+ return GestureGroup("exclusive", tuple(gestures))
438
+
439
+
312
440
  def serialize_gestures(
313
441
  specs: Sequence[Any],
314
442
  ) -> Tuple[List[Dict[str, Any]], Dict[str, Callable[..., Any]]]:
315
- """Split gesture descriptors into native config dicts and event routers.
443
+ """Flatten gesture descriptors into native config dicts and event routers.
444
+
445
+ Composition nodes ([`Simultaneous`][pythonnative.gestures.Simultaneous],
446
+ [`Race`][pythonnative.gestures.Race],
447
+ [`Exclusive`][pythonnative.gestures.Exclusive]) are flattened
448
+ depth-first; each resulting spec dict carries the relationship
449
+ metadata the recognizers need:
450
+
451
+ - ``"simultaneous"``: indices this gesture may be active alongside.
452
+ - ``"wait_for"``: indices that must *fail* before this gesture may
453
+ activate.
454
+
455
+ Two gestures that are not in each other's ``simultaneous`` sets
456
+ race: the first to activate causes the other to fail. Gestures in
457
+ the top-level list (outside any composition node) are mutually
458
+ simultaneous.
316
459
 
317
460
  Args:
318
461
  specs: The value of an element's ``gestures`` prop. Plain dicts
319
- are passed through untouched (no callbacks to route).
462
+ are passed through with relationship metadata attached (no
463
+ callbacks to route).
320
464
 
321
465
  Returns:
322
466
  ``(clean_specs, events)`` where ``clean_specs`` is a list of
323
- JSON-ish config dicts (one per gesture, in order) and
467
+ JSON-ish config dicts (one per leaf gesture, depth-first) and
324
468
  ``events`` maps ``"gesture:<i>"`` to a router that unpacks the
325
469
  native payload into a `GestureEvent` and invokes the right
326
470
  user callback.
327
471
  """
472
+ leaves: List[Any] = []
473
+ sim_pairs: Set[Tuple[int, int]] = set()
474
+ wait_pairs: Set[Tuple[int, int]] = set() # (waiter, target)
475
+
476
+ def _flatten(node: Any) -> List[int]:
477
+ if isinstance(node, GestureGroup):
478
+ subtree_leaves: List[List[int]] = [_flatten(child) for child in node.children]
479
+ for a_i in range(len(subtree_leaves)):
480
+ for b_i in range(a_i + 1, len(subtree_leaves)):
481
+ for a in subtree_leaves[a_i]:
482
+ for b in subtree_leaves[b_i]:
483
+ if node.mode == "simultaneous":
484
+ sim_pairs.add((a, b))
485
+ elif node.mode == "exclusive":
486
+ # Later members wait for earlier ones.
487
+ wait_pairs.add((b, a))
488
+ return [i for group in subtree_leaves for i in group]
489
+ leaves.append(node)
490
+ return [len(leaves) - 1]
491
+
492
+ top_level: List[List[int]] = [_flatten(node) for node in specs]
493
+ # Top-level entries are mutually simultaneous (flat-list default).
494
+ for a_i in range(len(top_level)):
495
+ for b_i in range(a_i + 1, len(top_level)):
496
+ for a in top_level[a_i]:
497
+ for b in top_level[b_i]:
498
+ sim_pairs.add((a, b))
499
+
328
500
  clean: List[Dict[str, Any]] = []
329
501
  events: Dict[str, Callable[..., Any]] = {}
330
- for i, spec in enumerate(specs):
331
- if isinstance(spec, _BaseGesture):
332
- clean.append(spec._to_spec())
502
+ for i, leaf in enumerate(leaves):
503
+ if isinstance(leaf, _BaseGesture):
504
+ spec = leaf._to_spec()
333
505
 
334
- def _router(payload: Dict[str, Any], _spec: _BaseGesture = spec) -> None:
506
+ def _router(payload: Dict[str, Any], _spec: _BaseGesture = leaf) -> None:
335
507
  _spec._dispatch(event_from_payload(payload))
336
508
 
337
509
  events[f"gesture:{i}"] = _router
338
- elif isinstance(spec, dict):
339
- clean.append(dict(spec))
510
+ elif isinstance(leaf, dict):
511
+ spec = dict(leaf)
512
+ else:
513
+ spec = {"kind": ""}
514
+ spec["simultaneous"] = sorted({b for a, b in sim_pairs if a == i} | {a for a, b in sim_pairs if b == i})
515
+ spec["wait_for"] = sorted({t for w, t in wait_pairs if w == i})
516
+ clean.append(spec)
340
517
  return clean, events
341
518
 
342
519
 
@@ -353,6 +530,10 @@ def serialize_gestures(
353
530
  EmitFn = Callable[[int, Dict[str, Any]], None]
354
531
  """``emit(gesture_index, payload)``: the arbiter's output channel."""
355
532
 
533
+ # Internal (never user-visible) state used by recognizers to tell the
534
+ # arbiter they can no longer succeed for this interaction.
535
+ _FAILED = "__failed"
536
+
356
537
 
357
538
  class _VelocityTracker:
358
539
  """Estimate pointer velocity from recent samples (points/second)."""
@@ -397,6 +578,14 @@ class _Recognizer:
397
578
  payload.update(fields)
398
579
  self._emit_fn(self.index, payload)
399
580
 
581
+ def fail(self) -> None:
582
+ """Report that this gesture can no longer succeed this interaction."""
583
+ self._emit_fn(self.index, {"kind": self.kind(), "state": _FAILED})
584
+
585
+ def force_fail(self, t: float) -> None:
586
+ """Abandon recognition without emitting anything (lost a race)."""
587
+ self.cancel(t)
588
+
400
589
  def kind(self) -> str:
401
590
  return str(self.config.get("kind", ""))
402
591
 
@@ -440,17 +629,22 @@ class _TapRecognizer(_Recognizer):
440
629
  self._tap_count = 0
441
630
  self._last_tap_time = 0.0
442
631
  self._failed = False
632
+ # Deadline for the *next* tap of a multi-tap (or None).
633
+ self._gap_deadline: Optional[float] = None
443
634
 
444
635
  _MAX_TAP_DURATION_S = 0.4
445
636
  _MULTI_TAP_GAP_S = 0.3
446
637
 
447
638
  def down(self, pointers: Dict[int, Tuple[float, float]], t: float) -> None:
448
639
  if len(pointers) != 1:
449
- self._failed = True
640
+ if not self._failed:
641
+ self._failed = True
642
+ self.fail()
450
643
  return
451
644
  if self._tap_count > 0 and t - self._last_tap_time > self._MULTI_TAP_GAP_S:
452
645
  self._tap_count = 0
453
646
  self._failed = False
647
+ self._gap_deadline = None
454
648
  self._down_pos = _centroid(pointers)
455
649
  self._down_time = t
456
650
 
@@ -460,6 +654,7 @@ class _TapRecognizer(_Recognizer):
460
654
  x, y = _centroid(pointers)
461
655
  if math.hypot(x - self._down_pos[0], y - self._down_pos[1]) > self._slop:
462
656
  self._failed = True
657
+ self.fail()
463
658
 
464
659
  def up(self, pointers: Dict[int, Tuple[float, float]], t: float, x: float, y: float) -> None:
465
660
  if self._failed or self._down_pos is None:
@@ -467,21 +662,36 @@ class _TapRecognizer(_Recognizer):
467
662
  return
468
663
  if t - self._down_time > self._MAX_TAP_DURATION_S:
469
664
  self._reset()
665
+ self.fail()
470
666
  return
471
667
  self._tap_count += 1
472
668
  self._last_tap_time = t
473
669
  if self._tap_count >= self._n_taps:
474
670
  self.emit(GestureState.ENDED, x=x, y=y)
475
671
  self._reset()
672
+ else:
673
+ # Waiting for the next tap; fail if it never arrives so
674
+ # gestures waiting on this one (Exclusive) can proceed.
675
+ self._gap_deadline = t + self._MULTI_TAP_GAP_S
476
676
  self._down_pos = None
477
677
 
478
678
  def cancel(self, t: float) -> None:
479
679
  self._reset()
480
680
 
681
+ def deadline(self) -> Optional[float]:
682
+ return self._gap_deadline
683
+
684
+ def poll(self, t: float) -> None:
685
+ if self._gap_deadline is not None and t >= self._gap_deadline:
686
+ self._gap_deadline = None
687
+ self._tap_count = 0
688
+ self.fail()
689
+
481
690
  def _reset(self) -> None:
482
691
  self._down_pos = None
483
692
  self._tap_count = 0 if self._tap_count >= self._n_taps else self._tap_count
484
693
  self._failed = False
694
+ self._gap_deadline = None
485
695
 
486
696
 
487
697
  class _LongPressRecognizer(_Recognizer):
@@ -506,10 +716,13 @@ class _LongPressRecognizer(_Recognizer):
506
716
  if self._active:
507
717
  self.emit(GestureState.CANCELLED, x=x, y=y)
508
718
  self._reset()
719
+ self.fail()
509
720
 
510
721
  def up(self, pointers: Dict[int, Tuple[float, float]], t: float, x: float, y: float) -> None:
511
722
  if self._active:
512
723
  self.emit(GestureState.ENDED, x=x, y=y)
724
+ elif self._down_pos is not None:
725
+ self.fail()
513
726
  self._reset()
514
727
 
515
728
  def cancel(self, t: float) -> None:
@@ -604,6 +817,8 @@ class _PanRecognizer(_Recognizer):
604
817
  )
605
818
  self._reset()
606
819
  elif not pointers:
820
+ if not self._active and self._origin is not None:
821
+ self.fail()
607
822
  self._reset()
608
823
  elif self._active:
609
824
  self._rebase(pointers)
@@ -631,22 +846,30 @@ class _PanRecognizer(_Recognizer):
631
846
 
632
847
 
633
848
  class _SwipeRecognizer(_Recognizer):
849
+ """Directional flick recognizer; also serves ``fling`` (adds pointer count)."""
850
+
634
851
  def __init__(self, index: int, config: Dict[str, Any], emit: EmitFn) -> None:
635
852
  super().__init__(index, config, emit)
636
853
  self._direction = str(config.get("direction", "any"))
637
854
  self._min_velocity = float(config.get("min_velocity", 300.0))
855
+ self._n_pointers = max(1, int(config.get("n_pointers", 1)))
638
856
  self._velocity = _VelocityTracker()
639
857
  self._tracking = False
858
+ self._max_pointers = 0
640
859
 
641
860
  def down(self, pointers: Dict[int, Tuple[float, float]], t: float) -> None:
642
- self._velocity.reset()
861
+ if not self._tracking:
862
+ self._velocity.reset()
863
+ self._tracking = True
864
+ self._max_pointers = 0
865
+ self._max_pointers = max(self._max_pointers, len(pointers))
643
866
  x, y = _centroid(pointers)
644
867
  self._velocity.add(x, y, t)
645
- self._tracking = True
646
868
 
647
869
  def move(self, pointers: Dict[int, Tuple[float, float]], t: float) -> None:
648
870
  if not self._tracking:
649
871
  return
872
+ self._max_pointers = max(self._max_pointers, len(pointers))
650
873
  x, y = _centroid(pointers)
651
874
  self._velocity.add(x, y, t)
652
875
 
@@ -657,13 +880,15 @@ class _SwipeRecognizer(_Recognizer):
657
880
  self._velocity.add(x, y, t)
658
881
  vx, vy = self._velocity.velocity()
659
882
  speed = math.hypot(vx, vy)
660
- if speed < self._min_velocity:
883
+ if speed < self._min_velocity or self._max_pointers < self._n_pointers:
884
+ self.fail()
661
885
  return
662
886
  if abs(vx) >= abs(vy):
663
887
  direction = "right" if vx > 0 else "left"
664
888
  else:
665
889
  direction = "down" if vy > 0 else "up"
666
890
  if self._direction not in ("any", direction):
891
+ self.fail()
667
892
  return
668
893
  self.emit(
669
894
  GestureState.ENDED,
@@ -672,10 +897,12 @@ class _SwipeRecognizer(_Recognizer):
672
897
  velocity_x=vx,
673
898
  velocity_y=vy,
674
899
  direction=direction,
900
+ pointer_count=self._max_pointers,
675
901
  )
676
902
 
677
903
  def cancel(self, t: float) -> None:
678
904
  self._tracking = False
905
+ self._max_pointers = 0
679
906
  self._velocity.reset()
680
907
 
681
908
 
@@ -720,6 +947,8 @@ class _PinchRecognizer(_Recognizer):
720
947
  if self._active and len(pointers) < 2:
721
948
  self.emit(GestureState.ENDED, x=x, y=y, scale=self._scale, pointer_count=len(pointers))
722
949
  self._reset()
950
+ elif not self._active and not pointers:
951
+ self.fail()
723
952
 
724
953
  def cancel(self, t: float) -> None:
725
954
  if self._active:
@@ -778,6 +1007,8 @@ class _RotationRecognizer(_Recognizer):
778
1007
  if self._active and len(pointers) < 2:
779
1008
  self.emit(GestureState.ENDED, x=x, y=y, rotation=self._rotation, pointer_count=len(pointers))
780
1009
  self._reset()
1010
+ elif not self._active and not pointers:
1011
+ self.fail()
781
1012
 
782
1013
  def cancel(self, t: float) -> None:
783
1014
  if self._active:
@@ -795,13 +1026,21 @@ _RECOGNIZERS: Dict[str, Any] = {
795
1026
  "long_press": _LongPressRecognizer,
796
1027
  "pan": _PanRecognizer,
797
1028
  "swipe": _SwipeRecognizer,
1029
+ "fling": _SwipeRecognizer,
798
1030
  "pinch": _PinchRecognizer,
799
1031
  "rotation": _RotationRecognizer,
800
1032
  }
801
1033
 
1034
+ # Arbitration states for one recognizer within one interaction.
1035
+ _POSSIBLE = "possible"
1036
+ _WAITING = "waiting" # activation buffered, awaiting wait_for targets
1037
+ _ACTIVE = "active"
1038
+ _DONE = "done"
1039
+ _ST_FAILED = "failed"
1040
+
802
1041
 
803
1042
  class GestureArbiter:
804
- """Turn a raw pointer-event stream into gesture event payloads.
1043
+ """Turn a raw pointer-event stream into arbitrated gesture payloads.
805
1044
 
806
1045
  One arbiter serves one view. The host backend feeds it normalized
807
1046
  pointer events (positions in the view's coordinate space, times in
@@ -809,28 +1048,70 @@ class GestureArbiter:
809
1048
  that forwards ``(gesture_index, payload)`` pairs to
810
1049
  [`dispatch_event`][pythonnative.events.dispatch_event].
811
1050
 
812
- Long-press needs a timer: after each pointer event, hosts should
813
- check [`next_deadline`][pythonnative.gestures.GestureArbiter.next_deadline]
1051
+ Beyond running each gesture's state machine, the arbiter enforces
1052
+ the relationships computed by
1053
+ [`serialize_gestures`][pythonnative.gestures.serialize_gestures]:
1054
+
1055
+ - Two gestures not in each other's ``"simultaneous"`` sets race;
1056
+ when one activates, the other is force-failed for the rest of
1057
+ the interaction.
1058
+ - A gesture with a ``"wait_for"`` set may only activate after all
1059
+ of those gestures have failed. Its output (including a buffered
1060
+ discrete completion, like a single tap waiting out a double-tap
1061
+ window) is held and either flushed on failure of the targets or
1062
+ discarded if a target succeeds.
1063
+
1064
+ Specs without relationship metadata (hand-built dicts) default to
1065
+ fully simultaneous, matching pre-composition behavior.
1066
+
1067
+ Timing: after each pointer event, hosts should check
1068
+ [`next_deadline`][pythonnative.gestures.GestureArbiter.next_deadline]
814
1069
  and schedule a [`poll`][pythonnative.gestures.GestureArbiter.poll]
815
- call for that time.
1070
+ call for that time (long-press activation and multi-tap windows).
816
1071
  """
817
1072
 
1073
+ _DISCRETE_KINDS = frozenset({"tap", "swipe", "fling"})
1074
+
818
1075
  def __init__(self, specs: Sequence[Dict[str, Any]], emit: EmitFn) -> None:
1076
+ self._emit_out = emit
819
1077
  self._pointers: Dict[int, Tuple[float, float]] = {}
820
1078
  self._recognizers: List[_Recognizer] = []
1079
+ self._indices: List[int] = []
1080
+ self._sim: Dict[int, Optional[Set[int]]] = {}
1081
+ self._wait_for: Dict[int, Set[int]] = {}
1082
+ self._states: Dict[int, str] = {}
1083
+ self._buffers: Dict[int, List[Dict[str, Any]]] = {}
1084
+ self._last_t = 0.0
821
1085
  for i, spec in enumerate(specs):
822
1086
  recognizer_cls = _RECOGNIZERS.get(str(spec.get("kind", "")))
823
- if recognizer_cls is not None:
824
- self._recognizers.append(recognizer_cls(i, spec, emit))
1087
+ if recognizer_cls is None:
1088
+ continue
1089
+ self._recognizers.append(recognizer_cls(i, spec, self._mediate))
1090
+ self._indices.append(i)
1091
+ sim = spec.get("simultaneous")
1092
+ # None means "simultaneous with everything" (back-compat
1093
+ # for hand-built spec dicts without metadata).
1094
+ self._sim[i] = None if sim is None else set(int(s) for s in sim)
1095
+ self._wait_for[i] = set(int(w) for w in spec.get("wait_for", ()) or ())
1096
+ self._states[i] = _POSSIBLE
1097
+
1098
+ # -- pointer input ---------------------------------------------------
825
1099
 
826
1100
  def pointer_down(self, pointer_id: int, x: float, y: float, t: float) -> None:
827
1101
  """Record a pointer press and advance every recognizer."""
1102
+ self._last_t = t
1103
+ if not self._pointers and not any(s == _WAITING for s in self._states.values()):
1104
+ # Fresh interaction: clear per-interaction verdicts.
1105
+ for i in self._indices:
1106
+ self._states[i] = _POSSIBLE
1107
+ self._buffers.clear()
828
1108
  self._pointers[pointer_id] = (x, y)
829
1109
  for recognizer in self._recognizers:
830
1110
  recognizer.down(self._pointers, t)
831
1111
 
832
1112
  def pointer_move(self, pointer_id: int, x: float, y: float, t: float) -> None:
833
1113
  """Record pointer travel and advance every recognizer."""
1114
+ self._last_t = t
834
1115
  if pointer_id not in self._pointers:
835
1116
  return
836
1117
  self._pointers[pointer_id] = (x, y)
@@ -839,18 +1120,25 @@ class GestureArbiter:
839
1120
 
840
1121
  def pointer_up(self, pointer_id: int, x: float, y: float, t: float) -> None:
841
1122
  """Record a pointer release and advance every recognizer."""
1123
+ self._last_t = t
842
1124
  self._pointers.pop(pointer_id, None)
843
1125
  for recognizer in self._recognizers:
844
1126
  recognizer.up(self._pointers, t, x, y)
845
1127
 
846
1128
  def cancel(self, t: float) -> None:
847
1129
  """Abort every in-flight gesture (e.g. touch stolen by a scroll parent)."""
1130
+ self._last_t = t
848
1131
  self._pointers.clear()
1132
+ self._buffers.clear()
1133
+ for i in self._indices:
1134
+ if self._states[i] == _WAITING:
1135
+ self._states[i] = _ST_FAILED
849
1136
  for recognizer in self._recognizers:
850
1137
  recognizer.cancel(t)
851
1138
 
852
1139
  def poll(self, t: float) -> None:
853
- """Advance time-based recognizers (long-press activation)."""
1140
+ """Advance time-based recognizers (long-press, multi-tap windows)."""
1141
+ self._last_t = t
854
1142
  for recognizer in self._recognizers:
855
1143
  recognizer.poll(t)
856
1144
 
@@ -868,6 +1156,130 @@ class GestureArbiter:
868
1156
  """
869
1157
  return any(isinstance(r, _PanRecognizer) and r.active for r in self._recognizers)
870
1158
 
1159
+ # -- arbitration -------------------------------------------------------
1160
+
1161
+ def _recognizer_for(self, index: int) -> Optional[_Recognizer]:
1162
+ for r in self._recognizers:
1163
+ if r.index == index:
1164
+ return r
1165
+ return None
1166
+
1167
+ def _is_simultaneous(self, a: int, b: int) -> bool:
1168
+ sim_a = self._sim.get(a)
1169
+ sim_b = self._sim.get(b)
1170
+ if sim_a is None or sim_b is None:
1171
+ return True
1172
+ return b in sim_a and a in sim_b
1173
+
1174
+ def _mediate(self, index: int, payload: Dict[str, Any]) -> None:
1175
+ state = payload.get("state")
1176
+ current = self._states.get(index, _POSSIBLE)
1177
+
1178
+ if state == _FAILED:
1179
+ if current in (_POSSIBLE, _WAITING):
1180
+ self._set_failed(index, discard_buffer=True)
1181
+ return
1182
+
1183
+ if current == _ST_FAILED:
1184
+ return
1185
+
1186
+ if current == _ACTIVE:
1187
+ self._emit_out(index, payload)
1188
+ if state == GestureState.ENDED:
1189
+ self._states[index] = _DONE
1190
+ self._on_resolved(index, succeeded=True)
1191
+ elif state == GestureState.CANCELLED:
1192
+ self._states[index] = _ST_FAILED
1193
+ self._on_resolved(index, succeeded=False)
1194
+ return
1195
+
1196
+ if current == _WAITING:
1197
+ # Recognition continues while the activation is parked;
1198
+ # keep buffering so a flush replays the full stream.
1199
+ self._buffers.setdefault(index, []).append(payload)
1200
+ return
1201
+
1202
+ # current == _POSSIBLE: this payload is an activation attempt
1203
+ # (BEGAN, or a discrete completion such as a tap's ENDED).
1204
+ self._request_activation(index, payload)
1205
+
1206
+ def _request_activation(self, index: int, payload: Dict[str, Any]) -> None:
1207
+ # Race check: blocked by any non-simultaneous gesture that has
1208
+ # already activated (or completed) this interaction.
1209
+ for j in self._indices:
1210
+ if j == index:
1211
+ continue
1212
+ if self._states[j] in (_ACTIVE, _DONE) and not self._is_simultaneous(index, j):
1213
+ self._set_failed(index, discard_buffer=True)
1214
+ return
1215
+
1216
+ targets = [t for t in self._wait_for.get(index, ()) if t in self._states]
1217
+ if any(self._states[t] in (_ACTIVE, _DONE) for t in targets):
1218
+ self._set_failed(index, discard_buffer=True)
1219
+ return
1220
+ if any(self._states[t] in (_POSSIBLE, _WAITING) for t in targets):
1221
+ self._states[index] = _WAITING
1222
+ self._buffers.setdefault(index, []).append(payload)
1223
+ return
1224
+
1225
+ self._activate(index, [payload])
1226
+
1227
+ def _activate(self, index: int, payloads: List[Dict[str, Any]]) -> None:
1228
+ discrete_done = bool(payloads) and payloads[-1].get("state") in (
1229
+ GestureState.ENDED,
1230
+ GestureState.CANCELLED,
1231
+ )
1232
+ self._states[index] = _DONE if discrete_done else _ACTIVE
1233
+ # Winning a race force-fails every unresolved non-simultaneous
1234
+ # competitor before any output, so their recognizers stand down
1235
+ # (no stray callbacks later in the interaction).
1236
+ for j in self._indices:
1237
+ if j == index:
1238
+ continue
1239
+ if self._states[j] in (_POSSIBLE, _WAITING) and not self._is_simultaneous(index, j):
1240
+ recognizer = self._recognizer_for(j)
1241
+ if recognizer is not None:
1242
+ recognizer.force_fail(self._last_t)
1243
+ self._set_failed(j, discard_buffer=True)
1244
+ for payload in payloads:
1245
+ self._emit_out(index, payload)
1246
+ if self._states[index] == _DONE:
1247
+ self._on_resolved(index, succeeded=True)
1248
+
1249
+ def _set_failed(self, index: int, discard_buffer: bool) -> None:
1250
+ if self._states.get(index) == _ST_FAILED:
1251
+ return
1252
+ self._states[index] = _ST_FAILED
1253
+ if discard_buffer:
1254
+ self._buffers.pop(index, None)
1255
+ self._on_resolved(index, succeeded=False)
1256
+
1257
+ def _on_resolved(self, index: int, succeeded: bool) -> None:
1258
+ """Re-evaluate gestures waiting on ``index`` after it resolves."""
1259
+ for waiter in self._indices:
1260
+ if self._states.get(waiter) != _WAITING:
1261
+ continue
1262
+ if index not in self._wait_for.get(waiter, ()):
1263
+ continue
1264
+ if succeeded:
1265
+ recognizer = self._recognizer_for(waiter)
1266
+ if recognizer is not None:
1267
+ recognizer.force_fail(self._last_t)
1268
+ self._set_failed(waiter, discard_buffer=True)
1269
+ continue
1270
+ targets = [t for t in self._wait_for.get(waiter, ()) if t in self._states]
1271
+ if any(self._states[t] in (_POSSIBLE, _WAITING) for t in targets):
1272
+ continue # still waiting on someone else
1273
+ if any(self._states[t] in (_ACTIVE, _DONE) for t in targets):
1274
+ recognizer = self._recognizer_for(waiter)
1275
+ if recognizer is not None:
1276
+ recognizer.force_fail(self._last_t)
1277
+ self._set_failed(waiter, discard_buffer=True)
1278
+ continue
1279
+ payloads = self._buffers.pop(waiter, [])
1280
+ self._states[waiter] = _POSSIBLE
1281
+ self._activate(waiter, payloads)
1282
+
871
1283
 
872
1284
  # Re-exported via ``pythonnative.gestures`` for handler-side construction.
873
1285
  def make_arbiter(specs: Sequence[Dict[str, Any]], emit: EmitFn) -> GestureArbiter: