pythonnative 0.21.0__py3-none-any.whl → 0.22.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.
@@ -0,0 +1,875 @@
1
+ """Native-backed gesture system.
2
+
3
+ Attach gestures to any view-like element via the ``gestures=`` prop:
4
+
5
+ ```python
6
+ import pythonnative as pn
7
+ from pythonnative import gestures
8
+
9
+
10
+ @pn.component
11
+ def Draggable():
12
+ tx = pn.use_animated_value(0.0)
13
+ ty = pn.use_animated_value(0.0)
14
+
15
+ def on_pan(event):
16
+ tx.set_value(event.translation_x)
17
+ ty.set_value(event.translation_y)
18
+
19
+ def on_end(event):
20
+ pn.Animated.spring(tx, to=0.0).start()
21
+ pn.Animated.spring(ty, to=0.0).start()
22
+
23
+ return pn.Animated.View(
24
+ pn.Text("Drag me"),
25
+ style={"transform": [{"translate_x": tx}, {"translate_y": ty}], "padding": 24},
26
+ gestures=[gestures.Pan(on_change=on_pan, on_end=on_end)],
27
+ )
28
+ ```
29
+
30
+ Each gesture descriptor is a frozen dataclass holding numeric
31
+ configuration plus user callbacks. The reconciler serializes the
32
+ configuration into plain dicts for the native handler (so prop diffing
33
+ never compares closures) and routes the callbacks through the
34
+ tag-based event channel. Recognition itself is native:
35
+
36
+ - **iOS** attaches real ``UIGestureRecognizer`` instances.
37
+ - **Android** feeds raw ``MotionEvent`` streams into the pure-Python
38
+ [`GestureArbiter`][pythonnative.gestures.GestureArbiter] below.
39
+ - **Desktop** feeds Tk pointer events into the same arbiter.
40
+
41
+ All gestures attached to one view recognize *simultaneously*; there is
42
+ no cross-gesture exclusivity arbitration yet.
43
+
44
+ Every callback receives a [`GestureEvent`][pythonnative.gestures.GestureEvent]
45
+ with position, translation, velocity, scale, and rotation populated as
46
+ appropriate for the gesture kind.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import math
52
+ from dataclasses import dataclass
53
+ from typing import Any, Callable, Dict, List, Literal, Optional, Sequence, Tuple
54
+
55
+ __all__ = [
56
+ "GestureState",
57
+ "GestureEvent",
58
+ "Tap",
59
+ "LongPress",
60
+ "Pan",
61
+ "Swipe",
62
+ "Pinch",
63
+ "Rotation",
64
+ "GestureArbiter",
65
+ "serialize_gestures",
66
+ ]
67
+
68
+
69
+ class GestureState:
70
+ """States reported on [`GestureEvent.state`][pythonnative.gestures.GestureEvent]."""
71
+
72
+ BEGAN = "began"
73
+ CHANGED = "changed"
74
+ ENDED = "ended"
75
+ CANCELLED = "cancelled"
76
+
77
+
78
+ GestureStateName = Literal["began", "changed", "ended", "cancelled"]
79
+
80
+ GestureCallback = Callable[["GestureEvent"], None]
81
+
82
+ SwipeDirection = Literal["any", "left", "right", "up", "down"]
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class GestureEvent:
87
+ """Snapshot delivered to gesture callbacks.
88
+
89
+ Attributes:
90
+ kind: Gesture kind (``"tap"``, ``"long_press"``, ``"pan"``,
91
+ ``"swipe"``, ``"pinch"``, ``"rotation"``).
92
+ state: One of [`GestureState`][pythonnative.gestures.GestureState].
93
+ x: Pointer x-position in the view's coordinate space (points).
94
+ y: Pointer y-position in the view's coordinate space (points).
95
+ translation_x: Horizontal displacement since the gesture
96
+ activated (pan only).
97
+ translation_y: Vertical displacement since the gesture
98
+ activated (pan only).
99
+ velocity_x: Horizontal pointer velocity in points/second
100
+ (pan and swipe).
101
+ velocity_y: Vertical pointer velocity in points/second
102
+ (pan and swipe).
103
+ scale: Pinch scale factor relative to activation (pinch only).
104
+ rotation: Rotation in radians relative to activation
105
+ (rotation only).
106
+ pointer_count: Number of pointers currently down.
107
+ direction: Resolved swipe direction (swipe only).
108
+ """
109
+
110
+ kind: str
111
+ state: GestureStateName
112
+ x: float = 0.0
113
+ y: float = 0.0
114
+ translation_x: float = 0.0
115
+ translation_y: float = 0.0
116
+ velocity_x: float = 0.0
117
+ velocity_y: float = 0.0
118
+ scale: float = 1.0
119
+ rotation: float = 0.0
120
+ pointer_count: int = 1
121
+ direction: Optional[str] = None
122
+
123
+
124
+ _EVENT_FIELDS = frozenset(
125
+ {
126
+ "kind",
127
+ "state",
128
+ "x",
129
+ "y",
130
+ "translation_x",
131
+ "translation_y",
132
+ "velocity_x",
133
+ "velocity_y",
134
+ "scale",
135
+ "rotation",
136
+ "pointer_count",
137
+ "direction",
138
+ }
139
+ )
140
+
141
+
142
+ def event_from_payload(payload: Dict[str, Any]) -> GestureEvent:
143
+ """Build a [`GestureEvent`][pythonnative.gestures.GestureEvent] from a payload dict.
144
+
145
+ Unknown keys are dropped so platform handlers can attach extra
146
+ diagnostics without breaking the public dataclass.
147
+ """
148
+ return GestureEvent(**{k: v for k, v in payload.items() if k in _EVENT_FIELDS})
149
+
150
+
151
+ # ======================================================================
152
+ # Public gesture descriptors
153
+ # ======================================================================
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class _BaseGesture:
158
+ """Shared callback slots for continuous gestures."""
159
+
160
+ on_begin: Optional[GestureCallback] = None
161
+ on_change: Optional[GestureCallback] = None
162
+ on_end: Optional[GestureCallback] = None
163
+
164
+ kind: str = ""
165
+
166
+ def _config(self) -> Dict[str, Any]:
167
+ return {}
168
+
169
+ def _to_spec(self) -> Dict[str, Any]:
170
+ spec: Dict[str, Any] = {"kind": self.kind}
171
+ spec.update(self._config())
172
+ return spec
173
+
174
+ def _dispatch(self, event: GestureEvent) -> None:
175
+ if event.state == GestureState.BEGAN:
176
+ callback = self.on_begin
177
+ elif event.state == GestureState.CHANGED:
178
+ callback = self.on_change
179
+ else:
180
+ callback = self.on_end
181
+ if callback is not None:
182
+ callback(event)
183
+
184
+
185
+ @dataclass(frozen=True)
186
+ class Tap(_BaseGesture):
187
+ """Recognize ``n_taps`` quick taps.
188
+
189
+ Attributes:
190
+ on_tap: Called once the tap (or multi-tap) completes.
191
+ n_taps: Number of consecutive taps required (``2`` for
192
+ double-tap).
193
+ max_distance: Maximum pointer travel (points) for a touch to
194
+ still count as a tap.
195
+ """
196
+
197
+ on_tap: Optional[GestureCallback] = None
198
+ n_taps: int = 1
199
+ max_distance: float = 12.0
200
+ kind: str = "tap"
201
+
202
+ def _config(self) -> Dict[str, Any]:
203
+ return {"n_taps": int(self.n_taps), "max_distance": float(self.max_distance)}
204
+
205
+ def _dispatch(self, event: GestureEvent) -> None:
206
+ if event.state == GestureState.ENDED and self.on_tap is not None:
207
+ self.on_tap(event)
208
+ else:
209
+ super()._dispatch(event)
210
+
211
+
212
+ @dataclass(frozen=True)
213
+ class LongPress(_BaseGesture):
214
+ """Recognize a sustained press.
215
+
216
+ ``on_long_press`` fires as soon as the press has been held for
217
+ ``min_duration_ms`` (matching ``UILongPressGestureRecognizer``);
218
+ ``on_end`` fires when the finger lifts.
219
+
220
+ Attributes:
221
+ on_long_press: Called at activation time.
222
+ min_duration_ms: Hold duration required to activate.
223
+ max_distance: Maximum pointer travel before the press fails.
224
+ """
225
+
226
+ on_long_press: Optional[GestureCallback] = None
227
+ min_duration_ms: float = 500.0
228
+ max_distance: float = 12.0
229
+ kind: str = "long_press"
230
+
231
+ def _config(self) -> Dict[str, Any]:
232
+ return {
233
+ "min_duration_ms": float(self.min_duration_ms),
234
+ "max_distance": float(self.max_distance),
235
+ }
236
+
237
+ def _dispatch(self, event: GestureEvent) -> None:
238
+ if event.state == GestureState.BEGAN and self.on_long_press is not None:
239
+ self.on_long_press(event)
240
+ else:
241
+ super()._dispatch(event)
242
+
243
+
244
+ @dataclass(frozen=True)
245
+ class Pan(_BaseGesture):
246
+ """Track a drag with translation and velocity.
247
+
248
+ Activates once the pointer travels ``min_distance`` points, then
249
+ reports ``on_change`` for every movement with translation measured
250
+ from the activation point, and ``on_end`` with release velocity.
251
+
252
+ Attributes:
253
+ min_distance: Travel (points) required before the pan activates.
254
+ min_pointers: Minimum pointers that must be down.
255
+ """
256
+
257
+ min_distance: float = 10.0
258
+ min_pointers: int = 1
259
+ kind: str = "pan"
260
+
261
+ def _config(self) -> Dict[str, Any]:
262
+ return {
263
+ "min_distance": float(self.min_distance),
264
+ "min_pointers": int(self.min_pointers),
265
+ }
266
+
267
+
268
+ @dataclass(frozen=True)
269
+ class Swipe(_BaseGesture):
270
+ """Recognize a quick directional flick.
271
+
272
+ Attributes:
273
+ on_swipe: Called once on release with the resolved
274
+ ``direction`` and release velocity.
275
+ direction: Required direction, or ``"any"``.
276
+ min_velocity: Minimum release speed in points/second.
277
+ """
278
+
279
+ on_swipe: Optional[GestureCallback] = None
280
+ direction: SwipeDirection = "any"
281
+ min_velocity: float = 300.0
282
+ kind: str = "swipe"
283
+
284
+ def _config(self) -> Dict[str, Any]:
285
+ return {"direction": str(self.direction), "min_velocity": float(self.min_velocity)}
286
+
287
+ def _dispatch(self, event: GestureEvent) -> None:
288
+ if event.state == GestureState.ENDED and self.on_swipe is not None:
289
+ self.on_swipe(event)
290
+ else:
291
+ super()._dispatch(event)
292
+
293
+
294
+ @dataclass(frozen=True)
295
+ class Pinch(_BaseGesture):
296
+ """Track a two-finger pinch; ``event.scale`` is relative to activation."""
297
+
298
+ kind: str = "pinch"
299
+
300
+
301
+ @dataclass(frozen=True)
302
+ class Rotation(_BaseGesture):
303
+ """Track a two-finger rotation; ``event.rotation`` is in radians."""
304
+
305
+ kind: str = "rotation"
306
+
307
+
308
+ GestureSpec = _BaseGesture
309
+ """Any gesture descriptor accepted by the ``gestures=`` prop."""
310
+
311
+
312
+ def serialize_gestures(
313
+ specs: Sequence[Any],
314
+ ) -> Tuple[List[Dict[str, Any]], Dict[str, Callable[..., Any]]]:
315
+ """Split gesture descriptors into native config dicts and event routers.
316
+
317
+ Args:
318
+ specs: The value of an element's ``gestures`` prop. Plain dicts
319
+ are passed through untouched (no callbacks to route).
320
+
321
+ Returns:
322
+ ``(clean_specs, events)`` where ``clean_specs`` is a list of
323
+ JSON-ish config dicts (one per gesture, in order) and
324
+ ``events`` maps ``"gesture:<i>"`` to a router that unpacks the
325
+ native payload into a `GestureEvent` and invokes the right
326
+ user callback.
327
+ """
328
+ clean: List[Dict[str, Any]] = []
329
+ events: Dict[str, Callable[..., Any]] = {}
330
+ for i, spec in enumerate(specs):
331
+ if isinstance(spec, _BaseGesture):
332
+ clean.append(spec._to_spec())
333
+
334
+ def _router(payload: Dict[str, Any], _spec: _BaseGesture = spec) -> None:
335
+ _spec._dispatch(event_from_payload(payload))
336
+
337
+ events[f"gesture:{i}"] = _router
338
+ elif isinstance(spec, dict):
339
+ clean.append(dict(spec))
340
+ return clean, events
341
+
342
+
343
+ # ======================================================================
344
+ # Pure-Python recognition engine (Android + desktop backends)
345
+ # ======================================================================
346
+ #
347
+ # iOS uses real UIGestureRecognizers. Android and the desktop preview
348
+ # receive raw pointer streams instead, which this arbiter turns into
349
+ # the same GestureEvent payloads. Keeping it in pure Python makes the
350
+ # state machines unit-testable with scripted event sequences and
351
+ # guarantees identical semantics on both backends.
352
+
353
+ EmitFn = Callable[[int, Dict[str, Any]], None]
354
+ """``emit(gesture_index, payload)`` — the arbiter's output channel."""
355
+
356
+
357
+ class _VelocityTracker:
358
+ """Estimate pointer velocity from recent samples (points/second)."""
359
+
360
+ __slots__ = ("_samples",)
361
+
362
+ _WINDOW_S = 0.1
363
+
364
+ def __init__(self) -> None:
365
+ self._samples: List[Tuple[float, float, float]] = []
366
+
367
+ def add(self, x: float, y: float, t: float) -> None:
368
+ self._samples.append((x, y, t))
369
+ cutoff = t - self._WINDOW_S
370
+ while len(self._samples) > 2 and self._samples[0][2] < cutoff:
371
+ self._samples.pop(0)
372
+
373
+ def velocity(self) -> Tuple[float, float]:
374
+ if len(self._samples) < 2:
375
+ return (0.0, 0.0)
376
+ x0, y0, t0 = self._samples[0]
377
+ x1, y1, t1 = self._samples[-1]
378
+ dt = t1 - t0
379
+ if dt <= 1e-6:
380
+ return (0.0, 0.0)
381
+ return ((x1 - x0) / dt, (y1 - y0) / dt)
382
+
383
+ def reset(self) -> None:
384
+ self._samples.clear()
385
+
386
+
387
+ class _Recognizer:
388
+ """Base class for one gesture's state machine."""
389
+
390
+ def __init__(self, index: int, config: Dict[str, Any], emit: EmitFn) -> None:
391
+ self.index = index
392
+ self.config = config
393
+ self._emit_fn = emit
394
+
395
+ def emit(self, state: str, **fields: Any) -> None:
396
+ payload: Dict[str, Any] = {"kind": self.kind(), "state": state}
397
+ payload.update(fields)
398
+ self._emit_fn(self.index, payload)
399
+
400
+ def kind(self) -> str:
401
+ return str(self.config.get("kind", ""))
402
+
403
+ # Event hooks — ``pointers`` maps pointer id -> (x, y).
404
+ def down(self, pointers: Dict[int, Tuple[float, float]], t: float) -> None:
405
+ pass
406
+
407
+ def move(self, pointers: Dict[int, Tuple[float, float]], t: float) -> None:
408
+ pass
409
+
410
+ def up(self, pointers: Dict[int, Tuple[float, float]], t: float, x: float, y: float) -> None:
411
+ pass
412
+
413
+ def cancel(self, t: float) -> None:
414
+ pass
415
+
416
+ def deadline(self) -> Optional[float]:
417
+ """Next time `poll` should run, or ``None``."""
418
+ return None
419
+
420
+ def poll(self, t: float) -> None:
421
+ pass
422
+
423
+
424
+ def _centroid(pointers: Dict[int, Tuple[float, float]]) -> Tuple[float, float]:
425
+ if not pointers:
426
+ return (0.0, 0.0)
427
+ xs = sum(p[0] for p in pointers.values())
428
+ ys = sum(p[1] for p in pointers.values())
429
+ n = len(pointers)
430
+ return (xs / n, ys / n)
431
+
432
+
433
+ class _TapRecognizer(_Recognizer):
434
+ def __init__(self, index: int, config: Dict[str, Any], emit: EmitFn) -> None:
435
+ super().__init__(index, config, emit)
436
+ self._n_taps = max(1, int(config.get("n_taps", 1)))
437
+ self._slop = float(config.get("max_distance", 12.0))
438
+ self._down_pos: Optional[Tuple[float, float]] = None
439
+ self._down_time = 0.0
440
+ self._tap_count = 0
441
+ self._last_tap_time = 0.0
442
+ self._failed = False
443
+
444
+ _MAX_TAP_DURATION_S = 0.4
445
+ _MULTI_TAP_GAP_S = 0.3
446
+
447
+ def down(self, pointers: Dict[int, Tuple[float, float]], t: float) -> None:
448
+ if len(pointers) != 1:
449
+ self._failed = True
450
+ return
451
+ if self._tap_count > 0 and t - self._last_tap_time > self._MULTI_TAP_GAP_S:
452
+ self._tap_count = 0
453
+ self._failed = False
454
+ self._down_pos = _centroid(pointers)
455
+ self._down_time = t
456
+
457
+ def move(self, pointers: Dict[int, Tuple[float, float]], t: float) -> None:
458
+ if self._failed or self._down_pos is None:
459
+ return
460
+ x, y = _centroid(pointers)
461
+ if math.hypot(x - self._down_pos[0], y - self._down_pos[1]) > self._slop:
462
+ self._failed = True
463
+
464
+ def up(self, pointers: Dict[int, Tuple[float, float]], t: float, x: float, y: float) -> None:
465
+ if self._failed or self._down_pos is None:
466
+ self._reset()
467
+ return
468
+ if t - self._down_time > self._MAX_TAP_DURATION_S:
469
+ self._reset()
470
+ return
471
+ self._tap_count += 1
472
+ self._last_tap_time = t
473
+ if self._tap_count >= self._n_taps:
474
+ self.emit(GestureState.ENDED, x=x, y=y)
475
+ self._reset()
476
+ self._down_pos = None
477
+
478
+ def cancel(self, t: float) -> None:
479
+ self._reset()
480
+
481
+ def _reset(self) -> None:
482
+ self._down_pos = None
483
+ self._tap_count = 0 if self._tap_count >= self._n_taps else self._tap_count
484
+ self._failed = False
485
+
486
+
487
+ class _LongPressRecognizer(_Recognizer):
488
+ def __init__(self, index: int, config: Dict[str, Any], emit: EmitFn) -> None:
489
+ super().__init__(index, config, emit)
490
+ self._duration_s = float(config.get("min_duration_ms", 500.0)) / 1000.0
491
+ self._slop = float(config.get("max_distance", 12.0))
492
+ self._down_pos: Optional[Tuple[float, float]] = None
493
+ self._deadline: Optional[float] = None
494
+ self._active = False
495
+
496
+ def down(self, pointers: Dict[int, Tuple[float, float]], t: float) -> None:
497
+ self._down_pos = _centroid(pointers)
498
+ self._deadline = t + self._duration_s
499
+ self._active = False
500
+
501
+ def move(self, pointers: Dict[int, Tuple[float, float]], t: float) -> None:
502
+ if self._down_pos is None:
503
+ return
504
+ x, y = _centroid(pointers)
505
+ if math.hypot(x - self._down_pos[0], y - self._down_pos[1]) > self._slop:
506
+ if self._active:
507
+ self.emit(GestureState.CANCELLED, x=x, y=y)
508
+ self._reset()
509
+
510
+ def up(self, pointers: Dict[int, Tuple[float, float]], t: float, x: float, y: float) -> None:
511
+ if self._active:
512
+ self.emit(GestureState.ENDED, x=x, y=y)
513
+ self._reset()
514
+
515
+ def cancel(self, t: float) -> None:
516
+ if self._active:
517
+ self.emit(GestureState.CANCELLED)
518
+ self._reset()
519
+
520
+ def deadline(self) -> Optional[float]:
521
+ return self._deadline
522
+
523
+ def poll(self, t: float) -> None:
524
+ if self._deadline is None or self._down_pos is None or self._active:
525
+ return
526
+ if t >= self._deadline:
527
+ self._active = True
528
+ self._deadline = None
529
+ self.emit(GestureState.BEGAN, x=self._down_pos[0], y=self._down_pos[1])
530
+
531
+ def _reset(self) -> None:
532
+ self._down_pos = None
533
+ self._deadline = None
534
+ self._active = False
535
+
536
+
537
+ class _PanRecognizer(_Recognizer):
538
+ def __init__(self, index: int, config: Dict[str, Any], emit: EmitFn) -> None:
539
+ super().__init__(index, config, emit)
540
+ self._min_distance = float(config.get("min_distance", 10.0))
541
+ self._min_pointers = max(1, int(config.get("min_pointers", 1)))
542
+ self._origin: Optional[Tuple[float, float]] = None
543
+ self._anchor: Optional[Tuple[float, float]] = None
544
+ self._active = False
545
+ self._velocity = _VelocityTracker()
546
+ self._last_translation: Tuple[float, float] = (0.0, 0.0)
547
+
548
+ @property
549
+ def active(self) -> bool:
550
+ return self._active
551
+
552
+ def down(self, pointers: Dict[int, Tuple[float, float]], t: float) -> None:
553
+ if len(pointers) < self._min_pointers:
554
+ return
555
+ if self._origin is None:
556
+ self._origin = _centroid(pointers)
557
+ self._velocity.reset()
558
+ x, y = self._origin
559
+ self._velocity.add(x, y, t)
560
+ else:
561
+ # Pointer count changed; re-anchor so the centroid jump
562
+ # doesn't teleport the translation.
563
+ self._rebase(pointers)
564
+
565
+ def move(self, pointers: Dict[int, Tuple[float, float]], t: float) -> None:
566
+ if self._origin is None or len(pointers) < self._min_pointers:
567
+ return
568
+ x, y = _centroid(pointers)
569
+ self._velocity.add(x, y, t)
570
+ if not self._active:
571
+ if math.hypot(x - self._origin[0], y - self._origin[1]) < self._min_distance:
572
+ return
573
+ self._active = True
574
+ self._anchor = (x, y)
575
+ self.emit(GestureState.BEGAN, x=x, y=y, pointer_count=len(pointers))
576
+ return
577
+ assert self._anchor is not None
578
+ vx, vy = self._velocity.velocity()
579
+ self._last_translation = (x - self._anchor[0], y - self._anchor[1])
580
+ self.emit(
581
+ GestureState.CHANGED,
582
+ x=x,
583
+ y=y,
584
+ translation_x=self._last_translation[0],
585
+ translation_y=self._last_translation[1],
586
+ velocity_x=vx,
587
+ velocity_y=vy,
588
+ pointer_count=len(pointers),
589
+ )
590
+
591
+ def up(self, pointers: Dict[int, Tuple[float, float]], t: float, x: float, y: float) -> None:
592
+ if self._active and len(pointers) < self._min_pointers:
593
+ vx, vy = self._velocity.velocity()
594
+ anchor = self._anchor or (x, y)
595
+ self.emit(
596
+ GestureState.ENDED,
597
+ x=x,
598
+ y=y,
599
+ translation_x=x - anchor[0],
600
+ translation_y=y - anchor[1],
601
+ velocity_x=vx,
602
+ velocity_y=vy,
603
+ pointer_count=len(pointers),
604
+ )
605
+ self._reset()
606
+ elif not pointers:
607
+ self._reset()
608
+ elif self._active:
609
+ self._rebase(pointers)
610
+
611
+ def cancel(self, t: float) -> None:
612
+ if self._active:
613
+ self.emit(GestureState.CANCELLED)
614
+ self._reset()
615
+
616
+ def _rebase(self, pointers: Dict[int, Tuple[float, float]]) -> None:
617
+ """Re-anchor after a pointer-count change, preserving translation."""
618
+ if not self._active or self._anchor is None:
619
+ self._origin = _centroid(pointers)
620
+ return
621
+ x, y = _centroid(pointers)
622
+ prev_tx, prev_ty = self._last_translation
623
+ self._anchor = (x - prev_tx, y - prev_ty)
624
+
625
+ def _reset(self) -> None:
626
+ self._origin = None
627
+ self._anchor = None
628
+ self._active = False
629
+ self._velocity.reset()
630
+ self._last_translation = (0.0, 0.0)
631
+
632
+
633
+ class _SwipeRecognizer(_Recognizer):
634
+ def __init__(self, index: int, config: Dict[str, Any], emit: EmitFn) -> None:
635
+ super().__init__(index, config, emit)
636
+ self._direction = str(config.get("direction", "any"))
637
+ self._min_velocity = float(config.get("min_velocity", 300.0))
638
+ self._velocity = _VelocityTracker()
639
+ self._tracking = False
640
+
641
+ def down(self, pointers: Dict[int, Tuple[float, float]], t: float) -> None:
642
+ self._velocity.reset()
643
+ x, y = _centroid(pointers)
644
+ self._velocity.add(x, y, t)
645
+ self._tracking = True
646
+
647
+ def move(self, pointers: Dict[int, Tuple[float, float]], t: float) -> None:
648
+ if not self._tracking:
649
+ return
650
+ x, y = _centroid(pointers)
651
+ self._velocity.add(x, y, t)
652
+
653
+ def up(self, pointers: Dict[int, Tuple[float, float]], t: float, x: float, y: float) -> None:
654
+ if not self._tracking or pointers:
655
+ return
656
+ self._tracking = False
657
+ self._velocity.add(x, y, t)
658
+ vx, vy = self._velocity.velocity()
659
+ speed = math.hypot(vx, vy)
660
+ if speed < self._min_velocity:
661
+ return
662
+ if abs(vx) >= abs(vy):
663
+ direction = "right" if vx > 0 else "left"
664
+ else:
665
+ direction = "down" if vy > 0 else "up"
666
+ if self._direction not in ("any", direction):
667
+ return
668
+ self.emit(
669
+ GestureState.ENDED,
670
+ x=x,
671
+ y=y,
672
+ velocity_x=vx,
673
+ velocity_y=vy,
674
+ direction=direction,
675
+ )
676
+
677
+ def cancel(self, t: float) -> None:
678
+ self._tracking = False
679
+ self._velocity.reset()
680
+
681
+
682
+ class _PinchRecognizer(_Recognizer):
683
+ def __init__(self, index: int, config: Dict[str, Any], emit: EmitFn) -> None:
684
+ super().__init__(index, config, emit)
685
+ self._initial: Optional[float] = None
686
+ self._active = False
687
+ self._scale = 1.0
688
+
689
+ def _span(self, pointers: Dict[int, Tuple[float, float]]) -> Optional[float]:
690
+ if len(pointers) < 2:
691
+ return None
692
+ pts = list(pointers.values())[:2]
693
+ return math.hypot(pts[1][0] - pts[0][0], pts[1][1] - pts[0][1])
694
+
695
+ def down(self, pointers: Dict[int, Tuple[float, float]], t: float) -> None:
696
+ span = self._span(pointers)
697
+ if span is not None and span > 0 and self._initial is None:
698
+ self._initial = span
699
+ self._active = True
700
+ x, y = _centroid(pointers)
701
+ self.emit(GestureState.BEGAN, x=x, y=y, scale=1.0, pointer_count=len(pointers))
702
+
703
+ def move(self, pointers: Dict[int, Tuple[float, float]], t: float) -> None:
704
+ if not self._active or self._initial is None:
705
+ return
706
+ span = self._span(pointers)
707
+ if span is None or span <= 0:
708
+ return
709
+ self._scale = span / self._initial
710
+ x, y = _centroid(pointers)
711
+ self.emit(
712
+ GestureState.CHANGED,
713
+ x=x,
714
+ y=y,
715
+ scale=self._scale,
716
+ pointer_count=len(pointers),
717
+ )
718
+
719
+ def up(self, pointers: Dict[int, Tuple[float, float]], t: float, x: float, y: float) -> None:
720
+ if self._active and len(pointers) < 2:
721
+ self.emit(GestureState.ENDED, x=x, y=y, scale=self._scale, pointer_count=len(pointers))
722
+ self._reset()
723
+
724
+ def cancel(self, t: float) -> None:
725
+ if self._active:
726
+ self.emit(GestureState.CANCELLED, scale=self._scale)
727
+ self._reset()
728
+
729
+ def _reset(self) -> None:
730
+ self._initial = None
731
+ self._active = False
732
+ self._scale = 1.0
733
+
734
+
735
+ class _RotationRecognizer(_Recognizer):
736
+ def __init__(self, index: int, config: Dict[str, Any], emit: EmitFn) -> None:
737
+ super().__init__(index, config, emit)
738
+ self._initial: Optional[float] = None
739
+ self._active = False
740
+ self._rotation = 0.0
741
+
742
+ def _angle(self, pointers: Dict[int, Tuple[float, float]]) -> Optional[float]:
743
+ if len(pointers) < 2:
744
+ return None
745
+ pts = list(pointers.values())[:2]
746
+ return math.atan2(pts[1][1] - pts[0][1], pts[1][0] - pts[0][0])
747
+
748
+ def down(self, pointers: Dict[int, Tuple[float, float]], t: float) -> None:
749
+ angle = self._angle(pointers)
750
+ if angle is not None and self._initial is None:
751
+ self._initial = angle
752
+ self._active = True
753
+ x, y = _centroid(pointers)
754
+ self.emit(GestureState.BEGAN, x=x, y=y, rotation=0.0, pointer_count=len(pointers))
755
+
756
+ def move(self, pointers: Dict[int, Tuple[float, float]], t: float) -> None:
757
+ if not self._active or self._initial is None:
758
+ return
759
+ angle = self._angle(pointers)
760
+ if angle is None:
761
+ return
762
+ delta = angle - self._initial
763
+ while delta > math.pi:
764
+ delta -= 2 * math.pi
765
+ while delta < -math.pi:
766
+ delta += 2 * math.pi
767
+ self._rotation = delta
768
+ x, y = _centroid(pointers)
769
+ self.emit(
770
+ GestureState.CHANGED,
771
+ x=x,
772
+ y=y,
773
+ rotation=self._rotation,
774
+ pointer_count=len(pointers),
775
+ )
776
+
777
+ def up(self, pointers: Dict[int, Tuple[float, float]], t: float, x: float, y: float) -> None:
778
+ if self._active and len(pointers) < 2:
779
+ self.emit(GestureState.ENDED, x=x, y=y, rotation=self._rotation, pointer_count=len(pointers))
780
+ self._reset()
781
+
782
+ def cancel(self, t: float) -> None:
783
+ if self._active:
784
+ self.emit(GestureState.CANCELLED, rotation=self._rotation)
785
+ self._reset()
786
+
787
+ def _reset(self) -> None:
788
+ self._initial = None
789
+ self._active = False
790
+ self._rotation = 0.0
791
+
792
+
793
+ _RECOGNIZERS: Dict[str, Any] = {
794
+ "tap": _TapRecognizer,
795
+ "long_press": _LongPressRecognizer,
796
+ "pan": _PanRecognizer,
797
+ "swipe": _SwipeRecognizer,
798
+ "pinch": _PinchRecognizer,
799
+ "rotation": _RotationRecognizer,
800
+ }
801
+
802
+
803
+ class GestureArbiter:
804
+ """Turn a raw pointer-event stream into gesture event payloads.
805
+
806
+ One arbiter serves one view. The host backend feeds it normalized
807
+ pointer events (positions in the view's coordinate space, times in
808
+ seconds — any monotonic clock) and provides an ``emit`` callback
809
+ that forwards ``(gesture_index, payload)`` pairs to
810
+ [`dispatch_event`][pythonnative.events.dispatch_event].
811
+
812
+ Long-press needs a timer: after each pointer event, hosts should
813
+ check [`next_deadline`][pythonnative.gestures.GestureArbiter.next_deadline]
814
+ and schedule a [`poll`][pythonnative.gestures.GestureArbiter.poll]
815
+ call for that time.
816
+ """
817
+
818
+ def __init__(self, specs: Sequence[Dict[str, Any]], emit: EmitFn) -> None:
819
+ self._pointers: Dict[int, Tuple[float, float]] = {}
820
+ self._recognizers: List[_Recognizer] = []
821
+ for i, spec in enumerate(specs):
822
+ recognizer_cls = _RECOGNIZERS.get(str(spec.get("kind", "")))
823
+ if recognizer_cls is not None:
824
+ self._recognizers.append(recognizer_cls(i, spec, emit))
825
+
826
+ def pointer_down(self, pointer_id: int, x: float, y: float, t: float) -> None:
827
+ """Record a pointer press and advance every recognizer."""
828
+ self._pointers[pointer_id] = (x, y)
829
+ for recognizer in self._recognizers:
830
+ recognizer.down(self._pointers, t)
831
+
832
+ def pointer_move(self, pointer_id: int, x: float, y: float, t: float) -> None:
833
+ """Record pointer travel and advance every recognizer."""
834
+ if pointer_id not in self._pointers:
835
+ return
836
+ self._pointers[pointer_id] = (x, y)
837
+ for recognizer in self._recognizers:
838
+ recognizer.move(self._pointers, t)
839
+
840
+ def pointer_up(self, pointer_id: int, x: float, y: float, t: float) -> None:
841
+ """Record a pointer release and advance every recognizer."""
842
+ self._pointers.pop(pointer_id, None)
843
+ for recognizer in self._recognizers:
844
+ recognizer.up(self._pointers, t, x, y)
845
+
846
+ def cancel(self, t: float) -> None:
847
+ """Abort every in-flight gesture (e.g. touch stolen by a scroll parent)."""
848
+ self._pointers.clear()
849
+ for recognizer in self._recognizers:
850
+ recognizer.cancel(t)
851
+
852
+ def poll(self, t: float) -> None:
853
+ """Advance time-based recognizers (long-press activation)."""
854
+ for recognizer in self._recognizers:
855
+ recognizer.poll(t)
856
+
857
+ def next_deadline(self) -> Optional[float]:
858
+ """Earliest time `poll` should be called, or ``None``."""
859
+ deadlines = [d for r in self._recognizers if (d := r.deadline()) is not None]
860
+ return min(deadlines) if deadlines else None
861
+
862
+ def has_active_pan(self) -> bool:
863
+ """Whether a pan gesture is currently activated.
864
+
865
+ Android handlers use this to call
866
+ ``requestDisallowInterceptTouchEvent`` so an enclosing
867
+ ScrollView doesn't steal the drag.
868
+ """
869
+ return any(isinstance(r, _PanRecognizer) and r.active for r in self._recognizers)
870
+
871
+
872
+ # Re-exported via ``pythonnative.gestures`` for handler-side construction.
873
+ def make_arbiter(specs: Sequence[Dict[str, Any]], emit: EmitFn) -> GestureArbiter:
874
+ """Build a [`GestureArbiter`][pythonnative.gestures.GestureArbiter] from serialized specs."""
875
+ return GestureArbiter(specs, emit)