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/__init__.py +1 -1
- pythonnative/animated.py +697 -62
- pythonnative/components.py +61 -7
- pythonnative/gestures.py +446 -34
- pythonnative/native_modules/net_info.py +73 -4
- pythonnative/native_modules/notifications.py +37 -9
- pythonnative/native_views/android.py +320 -16
- pythonnative/native_views/desktop.py +766 -47
- pythonnative/native_views/ios.py +522 -33
- pythonnative/reconciler.py +28 -0
- pythonnative/style.py +19 -0
- pythonnative/templates/android_template/app/build.gradle +4 -0
- pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +4 -0
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +60 -0
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNFrameLayout.kt +50 -0
- pythonnative/templates/ios_template/ios_template/Info.plist +5 -0
- {pythonnative-0.26.0.dist-info → pythonnative-0.27.0.dist-info}/METADATA +3 -3
- {pythonnative-0.26.0.dist-info → pythonnative-0.27.0.dist-info}/RECORD +22 -21
- {pythonnative-0.26.0.dist-info → pythonnative-0.27.0.dist-info}/WHEEL +0 -0
- {pythonnative-0.26.0.dist-info → pythonnative-0.27.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.26.0.dist-info → pythonnative-0.27.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.26.0.dist-info → pythonnative-0.27.0.dist-info}/top_level.txt +0 -0
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=[
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
130
|
+
(pan, swipe, and fling).
|
|
101
131
|
velocity_y: Vertical pointer velocity in points/second
|
|
102
|
-
(pan and
|
|
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
|
|
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
|
-
"""
|
|
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
|
|
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,
|
|
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,
|
|
331
|
-
if isinstance(
|
|
332
|
-
|
|
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 =
|
|
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(
|
|
339
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
813
|
-
|
|
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
|
|
824
|
-
|
|
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
|
|
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:
|