FazBrowse GitHub Viewer
|
Trending
|
URL:
|
Home
Tools:
[Download Repo ZIP]
[View Raw Code]
[Original HTTPS Page]
pythonnative/src/pythonnative/gestures.py at main · pythonnative/pythonnative · GitHub
pythonnative
/
pythonnative
Public
Notifications
You must be signed in to change notification settings
Fork
23
Star
131
Code
Issues
11
Pull requests
4
Actions
Projects
Security and quality
0
Insights
Additional navigation options
Code
Issues
Pull requests
Actions
Projects
Security and quality
Insights
Expand file tree
Breadcrumbs
pythonnative
/
src
/
pythonnative
/
gestures.py
Copy path
More file actions
More file actions
Latest commit
History
History
History
1636 lines (1370 loc) · 60.8 KB
Breadcrumbs
pythonnative
/
src
/
pythonnative
/
gestures.py
Copy path
File metadata and controls
1636 lines (1370 loc) · 60.8 KB
Raw
Copy raw file
Download raw file
Open symbols panel
Edit and raw actions
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Native-backed gesture system with composition and arbitration.
Attach gestures to any view-like element via the ``gestures=`` prop:
```python
import pythonnative as pn
from pythonnative import gestures
@pn.component
def Draggable():
tx = pn.use_animated_value(0.0)
ty = pn.use_animated_value(0.0)
def on_end(event):
pn.Animated.spring(tx, to=0.0).start()
pn.Animated.spring(ty, to=0.0).start()
return pn.Animated.View(
pn.Text("Drag me"),
style={"transform": [{"translate_x": tx}, {"translate_y": ty}], "padding": 24},
gestures=[
gestures.Pan(
on_change=pn.Animated.event(translation_x=tx, translation_y=ty),
on_end=on_end,
)
],
)
```
Each gesture descriptor is a frozen dataclass holding numeric
configuration plus user callbacks. The reconciler serializes the
configuration into plain dicts for the native handler (so prop diffing
never compares closures) and routes the callbacks through the
tag-based event channel. Recognition itself is native:
- **iOS** attaches real ``UIGestureRecognizer`` instances
(``PythonNativeKit``).
- **Android** runs an equivalent recognizer set in Kotlin (the
``pythonnative`` Gradle module) on top of ``MotionEvent`` streams.
- **The browser preview** feeds DOM pointer events into the pure-Python
[`GestureArbiter`][pythonnative.gestures.GestureArbiter] below, which
doubles as the executable specification for the native ports.
Composition
-----------
Gestures in a plain ``gestures=[...]`` list all recognize
*simultaneously* (a press ripple plus a pan plus a pinch can all run at
once). To control how gestures interact, wrap them in composition
nodes, which nest arbitrarily:
- [`Simultaneous`][pythonnative.gestures.Simultaneous]: members may all
activate together (the flat-list default, useful inside other nodes).
- [`Race`][pythonnative.gestures.Race]: the first member to activate
wins; the rest fail for the remainder of the interaction.
- [`Exclusive`][pythonnative.gestures.Exclusive]: priority order. A
member may only activate after every member listed *before* it has
failed. ``Exclusive(double_tap, single_tap)`` is the classic
double-tap-wins arrangement: the single tap fires only after the
double-tap window expires.
```python
gestures=[
gestures.Race(
gestures.Pan(on_change=drag),
gestures.LongPress(on_long_press=show_menu),
),
]
```
Every callback receives a [`GestureEvent`][pythonnative.gestures.GestureEvent]
with position, translation, velocity, scale, and rotation populated as
appropriate for the gesture kind.
Every descriptor accepts ``enabled=False`` to keep it in the list (so
sibling indices and composition stay stable) while it neither
recognizes nor takes part in arbitration. [`Pan`][pythonnative.gestures.Pan]
adds React Native Gesture Handler's activation criteria
(``active_offset_x``, ``fail_offset_y``, ``max_pointers``,
``min_velocity``, ...); the rules are spelled out on the class and
implemented identically by the arbiter below and by the native
recognizers.
"""
from
__future__
import
annotations
import
math
from
dataclasses
import
dataclass
,
field
from
enum
import
Enum
from
typing
import
Any
,
Callable
,
Dict
,
List
,
Literal
,
Optional
,
Sequence
,
Set
,
Tuple
,
Union
__all__
=
[
"DEFAULT_PAN_MIN_DISTANCE"
,
"GestureState"
,
"GestureEvent"
,
"GestureSpec"
,
"SwipeDirection"
,
"Tap"
,
"LongPress"
,
"Pan"
,
"Swipe"
,
"Fling"
,
"Pinch"
,
"Rotation"
,
"Simultaneous"
,
"Race"
,
"Exclusive"
,
"GestureGroup"
,
"GestureArbiter"
,
"serialize_gestures"
,
]
class
GestureState
(
str
,
Enum
):
"""Lifecycle states reported on [`GestureEvent.state`][pythonnative.gestures.GestureEvent].
A ``str`` enum, so members compare equal to their wire value
(``GestureState.ENDED == "ended"``) and serialize as plain strings
across the native bridge, while callers get exhaustive
``match`` support and autocomplete.
Attributes:
BEGAN: The gesture activated (first callback).
CHANGED: A continuous gesture updated (pan, pinch, rotation).
ENDED: The gesture completed successfully.
CANCELLED: The gesture was interrupted (lost arbitration, view
unmounted, pointer left the window).
"""
BEGAN
=
"began"
CHANGED
=
"changed"
ENDED
=
"ended"
CANCELLED
=
"cancelled"
def
__str__
(
self
)
->
str
:
return
self
.
value
GestureCallback
=
Callable
[[
"GestureEvent"
],
Any
]
SwipeDirection
=
Literal
[
"left"
,
"right"
,
"up"
,
"down"
]
"""A resolved swipe or fling direction; ``None`` on a descriptor means any direction."""
_SWIPE_DIRECTIONS
=
frozenset
({
"left"
,
"right"
,
"up"
,
"down"
})
Offset
=
Union
[
float
,
Tuple
[
float
,
float
]]
"""A pan activation or failure bound: one number or a ``(negative_bound, positive_bound)`` pair."""
DEFAULT_PAN_MIN_DISTANCE
=
10.0
"""Points a pan must travel before activating when no other activation criterion is given."""
@
dataclass
(
frozen
=
True
)
class
GestureEvent
:
"""Snapshot delivered to gesture callbacks.
Attributes:
kind: Gesture kind (``"tap"``, ``"long_press"``, ``"pan"``,
``"swipe"``, ``"fling"``, ``"pinch"``, ``"rotation"``).
state: One of [`GestureState`][pythonnative.gestures.GestureState].
x: Pointer x-position in the view's coordinate space (points).
y: Pointer y-position in the view's coordinate space (points).
absolute_x: Pointer x-position in the window's coordinate
space (points).
absolute_y: Pointer y-position in the window's coordinate
space (points).
translation_x: Horizontal displacement since the gesture
activated (pan only).
translation_y: Vertical displacement since the gesture
activated (pan only).
velocity_x: Horizontal pointer velocity in points/second
(pan, swipe, and fling).
velocity_y: Vertical pointer velocity in points/second
(pan, swipe, and fling).
scale: Pinch scale factor relative to activation (pinch only).
rotation: Rotation in radians relative to activation
(rotation only).
pointer_count: Number of pointers currently down.
direction: Resolved swipe/fling direction, or ``None`` for
other gesture kinds.
"""
kind
:
str
state
:
GestureState
x
:
float
=
0.0
y
:
float
=
0.0
absolute_x
:
float
=
0.0
absolute_y
:
float
=
0.0
translation_x
:
float
=
0.0
translation_y
:
float
=
0.0
velocity_x
:
float
=
0.0
velocity_y
:
float
=
0.0
scale
:
float
=
1.0
rotation
:
float
=
0.0
pointer_count
:
int
=
1
direction
:
Optional
[
SwipeDirection
]
=
None
def
__post_init__
(
self
)
->
None
:
# Payloads from the native bridge carry plain strings; coerce
# so ``event.state`` is always a ``GestureState`` member.
if
not
isinstance
(
self
.
state
,
GestureState
):
object
.
__setattr__
(
self
,
"state"
,
GestureState
(
self
.
state
))
_EVENT_FIELDS
=
frozenset
(
{
"kind"
,
"state"
,
"x"
,
"y"
,
"absolute_x"
,
"absolute_y"
,
"translation_x"
,
"translation_y"
,
"velocity_x"
,
"velocity_y"
,
"scale"
,
"rotation"
,
"pointer_count"
,
"direction"
,
}
)
def
event_from_payload
(
payload
:
Dict
[
str
,
Any
])
->
GestureEvent
:
"""Build a [`GestureEvent`][pythonnative.gestures.GestureEvent] from a payload dict.
Unknown keys are dropped so platform handlers can attach extra
diagnostics without breaking the public dataclass.
"""
return
GestureEvent
(
**
{
k
:
v
for
k
,
v
in
payload
.
items
()
if
k
in
_EVENT_FIELDS
})
# ======================================================================
# Public gesture descriptors
# ======================================================================
@
dataclass
(
frozen
=
True
)
class
_BaseGesture
:
"""Shared callback slots and the ``enabled`` switch for every gesture.
Attributes:
on_begin: Called when a continuous gesture activates.
on_change: Called on every update of a continuous gesture.
on_end: Called when the gesture ends or is cancelled.
enabled: When ``False`` the gesture stays in the list (indices
and composition are unchanged) but never recognizes and is
skipped by ``Race`` and ``Exclusive`` arbitration, so a
later ``Exclusive`` member doesn't wait for it.
"""
on_begin
:
Optional
[
GestureCallback
]
=
None
on_change
:
Optional
[
GestureCallback
]
=
None
on_end
:
Optional
[
GestureCallback
]
=
None
enabled
:
bool
=
True
kind
:
str
=
""
def
_config
(
self
)
->
Dict
[
str
,
Any
]:
return
{}
def
_to_spec
(
self
)
->
Dict
[
str
,
Any
]:
spec
:
Dict
[
str
,
Any
]
=
{
"kind"
:
self
.
kind
,
"enabled"
:
bool
(
self
.
enabled
)}
spec
.
update
(
self
.
_config
())
return
spec
def
_dispatch
(
self
,
event
:
GestureEvent
)
->
None
:
if
event
.
state
==
GestureState
.
BEGAN
:
callback
=
self
.
on_begin
elif
event
.
state
==
GestureState
.
CHANGED
:
callback
=
self
.
on_change
else
:
callback
=
self
.
on_end
if
callback
is
not
None
:
from
.
runtime
import
invoke
invoke
(
callback
,
event
)
@
dataclass
(
frozen
=
True
)
class
Tap
(
_BaseGesture
):
"""Recognize ``n_taps`` quick taps.
Attributes:
on_tap: Called once the tap (or multi-tap) completes.
n_taps: Number of consecutive taps required (``2`` for
double-tap).
max_distance: Maximum pointer travel (points) for a touch to
still count as a tap.
"""
on_tap
:
Optional
[
GestureCallback
]
=
None
n_taps
:
int
=
1
max_distance
:
float
=
12.0
kind
:
str
=
"tap"
def
_config
(
self
)
->
Dict
[
str
,
Any
]:
return
{
"n_taps"
:
int
(
self
.
n_taps
),
"max_distance"
:
float
(
self
.
max_distance
)}
def
_dispatch
(
self
,
event
:
GestureEvent
)
->
None
:
if
event
.
state
==
GestureState
.
ENDED
and
self
.
on_tap
is
not
None
:
from
.
runtime
import
invoke
invoke
(
self
.
on_tap
,
event
)
else
:
super
().
_dispatch
(
event
)
@
dataclass
(
frozen
=
True
)
class
LongPress
(
_BaseGesture
):
"""Recognize a sustained press.
``on_long_press`` fires as soon as the press has been held for
``min_duration_ms`` (matching ``UILongPressGestureRecognizer``);
``on_end`` fires when the finger lifts.
Attributes:
on_long_press: Called at activation time.
min_duration_ms: Hold duration required to activate.
max_distance: Maximum pointer travel before the press fails.
"""
on_long_press
:
Optional
[
GestureCallback
]
=
None
min_duration_ms
:
float
=
500.0
max_distance
:
float
=
12.0
kind
:
str
=
"long_press"
def
_config
(
self
)
->
Dict
[
str
,
Any
]:
return
{
"min_duration_ms"
:
float
(
self
.
min_duration_ms
),
"max_distance"
:
float
(
self
.
max_distance
),
}
def
_dispatch
(
self
,
event
:
GestureEvent
)
->
None
:
if
event
.
state
==
GestureState
.
BEGAN
and
self
.
on_long_press
is
not
None
:
from
.
runtime
import
invoke
invoke
(
self
.
on_long_press
,
event
)
else
:
super
().
_dispatch
(
event
)
def
_normalize_offset
(
name
:
str
,
value
:
Any
)
->
Optional
[
List
[
Optional
[
float
]]]:
"""Turn an ``Offset`` argument into the wire form ``[negative_bound, positive_bound]``.
A single number ``n`` is a one-sided threshold: ``n >= 0`` bounds the
positive direction only (``[None, n]``) and ``n < 0`` the negative
direction only (``[n, None]``). A pair is ``(negative_bound,
positive_bound)`` and must satisfy ``negative_bound <= 0 <=
positive_bound``; either side may be ``None`` for "unbounded".
"""
if
value
is
None
:
return
None
if
isinstance
(
value
,
bool
):
raise
TypeError
(
f"
{
name
}
must be a number or a (negative_bound, positive_bound) pair, not a bool"
)
if
isinstance
(
value
, (
int
,
float
)):
n
=
float
(
value
)
return
[
None
,
n
]
if
n
>=
0
else
[
n
,
None
]
try
:
lo
,
hi
=
value
except
(
TypeError
,
ValueError
):
raise
TypeError
(
f"
{
name
}
must be a number or a (negative_bound, positive_bound) pair (got
{
value
!r
}
)"
)
from
None
low
=
None
if
lo
is
None
else
float
(
lo
)
high
=
None
if
hi
is
None
else
float
(
hi
)
if
(
low
is
not
None
and
low
>
0.0
)
or
(
high
is
not
None
and
high
<
0.0
):
raise
ValueError
(
f"
{
name
}
pair must satisfy negative_bound <= 0 <= positive_bound (got
{
value
!r
}
)"
)
return
[
low
,
high
]
def
_crosses
(
bounds
:
Optional
[
List
[
Optional
[
float
]]],
delta
:
float
)
->
bool
:
"""Whether ``delta`` lies outside ``[negative_bound, positive_bound]`` (strict)."""
if
bounds
is
None
:
return
False
low
,
high
=
bounds
return
(
low
is
not
None
and
delta
<
low
)
or
(
high
is
not
None
and
delta
>
high
)
@
dataclass
(
frozen
=
True
)
class
Pan
(
_BaseGesture
):
"""Track a drag with translation and velocity.
Once the pan activates it reports ``on_change`` for every movement
with translation measured from the activation point, and ``on_end``
with the release velocity.
**Activation rules** (the same on iOS, Android, and the browser
preview). Before activation, with ``dx``/``dy`` the pointer travel
from where it first touched down:
1. If ``dx`` crosses ``fail_offset_x`` or ``dy`` crosses
``fail_offset_y``, the pan fails for the rest of the interaction.
2. Otherwise the pan activates as soon as any one criterion holds:
``dx`` crosses ``active_offset_x``; ``dy`` crosses
``active_offset_y``; the straight-line travel is at least
``min_distance``; or the pointer speed is at least
``min_velocity``.
"Crossing" a bound is strict: a single number ``n`` means ``dx > n``
when ``n >= 0`` and ``dx < n`` when ``n < 0``; a
``(negative_bound, positive_bound)`` pair means ``dx <
negative_bound or dx > positive_bound``, and the pair must satisfy
``negative_bound <= 0 <= positive_bound``. Following React Native
Gesture Handler, the default ``min_distance`` of
``DEFAULT_PAN_MIN_DISTANCE`` (10 points) applies only when none of
the offset or velocity criteria is given; pass ``min_distance``
explicitly to combine it with them.
Pointer counts: the pan tracks only while at least ``min_pointers``
are down. When more than ``max_pointers`` touch down, a pan that
hasn't activated fails and an active pan is cancelled.
Attributes:
min_distance: Travel (points) that activates the pan. ``None``
(default) means 10 points unless another activation
criterion is set.
min_pointers: Minimum pointers that must be down.
max_pointers: Maximum pointers allowed; ``None`` for no limit.
active_offset_x: Horizontal travel that activates the pan.
active_offset_y: Vertical travel that activates the pan.
fail_offset_x: Horizontal travel that fails the pan.
fail_offset_y: Vertical travel that fails the pan.
min_velocity: Pointer speed in points/second that activates the
pan regardless of distance.
Example:
```python
# A horizontal swipe-to-dismiss row inside a vertical list.
gestures.Pan(active_offset_x=(-20, 20), fail_offset_y=(-15, 15), on_change=drag)
```
"""
min_distance
:
Optional
[
float
]
=
None
min_pointers
:
int
=
1
max_pointers
:
Optional
[
int
]
=
None
active_offset_x
:
Optional
[
Offset
]
=
None
active_offset_y
:
Optional
[
Offset
]
=
None
fail_offset_x
:
Optional
[
Offset
]
=
None
fail_offset_y
:
Optional
[
Offset
]
=
None
min_velocity
:
Optional
[
float
]
=
None
kind
:
str
=
"pan"
def
__post_init__
(
self
)
->
None
:
for
name
in
(
"active_offset_x"
,
"active_offset_y"
,
"fail_offset_x"
,
"fail_offset_y"
):
_normalize_offset
(
name
,
getattr
(
self
,
name
))
if
self
.
min_distance
is
not
None
and
float
(
self
.
min_distance
)
<
0.0
:
raise
ValueError
(
"min_distance must be non-negative"
)
if
int
(
self
.
min_pointers
)
<
1
:
raise
ValueError
(
"min_pointers must be at least 1"
)
if
self
.
max_pointers
is
not
None
and
int
(
self
.
max_pointers
)
<
int
(
self
.
min_pointers
):
raise
ValueError
(
"max_pointers must be at least min_pointers"
)
if
self
.
min_velocity
is
not
None
and
float
(
self
.
min_velocity
)
<
0.0
:
raise
ValueError
(
"min_velocity must be non-negative"
)
def
_has_custom_activation
(
self
)
->
bool
:
return
any
(
value
is
not
None
for
value
in
(
self
.
active_offset_x
,
self
.
active_offset_y
,
self
.
fail_offset_x
,
self
.
fail_offset_y
,
self
.
min_velocity
,
)
)
def
_config
(
self
)
->
Dict
[
str
,
Any
]:
min_distance
:
Optional
[
float
]
if
self
.
min_distance
is
not
None
:
min_distance
=
float
(
self
.
min_distance
)
elif
self
.
_has_custom_activation
():
min_distance
=
None
else
:
min_distance
=
DEFAULT_PAN_MIN_DISTANCE
return
{
"min_distance"
:
min_distance
,
"min_pointers"
:
int
(
self
.
min_pointers
),
"max_pointers"
:
None
if
self
.
max_pointers
is
None
else
int
(
self
.
max_pointers
),
"active_offset_x"
:
_normalize_offset
(
"active_offset_x"
,
self
.
active_offset_x
),
"active_offset_y"
:
_normalize_offset
(
"active_offset_y"
,
self
.
active_offset_y
),
"fail_offset_x"
:
_normalize_offset
(
"fail_offset_x"
,
self
.
fail_offset_x
),
"fail_offset_y"
:
_normalize_offset
(
"fail_offset_y"
,
self
.
fail_offset_y
),
"min_velocity"
:
None
if
self
.
min_velocity
is
None
else
float
(
self
.
min_velocity
),
}
def
_check_direction
(
direction
:
Any
)
->
None
:
if
direction
is
not
None
and
direction
not
in
_SWIPE_DIRECTIONS
:
raise
ValueError
(
f"direction must be one of left, right, up, down, or None for any direction (got
{
direction
!r
}
)"
)
@
dataclass
(
frozen
=
True
)
class
Swipe
(
_BaseGesture
):
"""Recognize a quick directional flick.
Attributes:
on_swipe: Called once on release with the resolved
``direction`` and release velocity.
direction: Required direction, or ``None`` for any direction.
min_velocity: Minimum release speed in points/second.
"""
on_swipe
:
Optional
[
GestureCallback
]
=
None
direction
:
Optional
[
SwipeDirection
]
=
None
min_velocity
:
float
=
300.0
kind
:
str
=
"swipe"
def
__post_init__
(
self
)
->
None
:
_check_direction
(
self
.
direction
)
def
_config
(
self
)
->
Dict
[
str
,
Any
]:
return
{
"direction"
:
self
.
direction
or
"any"
,
"min_velocity"
:
float
(
self
.
min_velocity
)}
def
_dispatch
(
self
,
event
:
GestureEvent
)
->
None
:
if
event
.
state
==
GestureState
.
ENDED
and
self
.
on_swipe
is
not
None
:
from
.
runtime
import
invoke
invoke
(
self
.
on_swipe
,
event
)
else
:
super
().
_dispatch
(
event
)
@
dataclass
(
frozen
=
True
)
class
Fling
(
_BaseGesture
):
"""Recognize a quick multi-pointer directional flick.
Like [`Swipe`][pythonnative.gestures.Swipe] but with a pointer-count
requirement, mirroring React Native Gesture Handler's ``Fling``
(and iOS ``UISwipeGestureRecognizer`` with
``numberOfTouchesRequired``). A two-finger downward fling is a
common dismiss gesture:
```python
gestures.Fling(direction="down", n_pointers=2, on_fling=dismiss)
```
Attributes:
on_fling: Called once on release with the resolved
``direction`` and release velocity.
direction: Required direction, or ``None`` for any direction.
n_pointers: Number of pointers that must participate.
min_velocity: Minimum release speed in points/second.
"""
on_fling
:
Optional
[
GestureCallback
]
=
None
direction
:
Optional
[
SwipeDirection
]
=
None
n_pointers
:
int
=
1
min_velocity
:
float
=
300.0
kind
:
str
=
"fling"
def
__post_init__
(
self
)
->
None
:
_check_direction
(
self
.
direction
)
def
_config
(
self
)
->
Dict
[
str
,
Any
]:
return
{
"direction"
:
self
.
direction
or
"any"
,
"n_pointers"
:
int
(
self
.
n_pointers
),
"min_velocity"
:
float
(
self
.
min_velocity
),
}
def
_dispatch
(
self
,
event
:
GestureEvent
)
->
None
:
if
event
.
state
==
GestureState
.
ENDED
and
self
.
on_fling
is
not
None
:
from
.
runtime
import
invoke
invoke
(
self
.
on_fling
,
event
)
else
:
super
().
_dispatch
(
event
)
@
dataclass
(
frozen
=
True
)
class
Pinch
(
_BaseGesture
):
"""Track a two-finger pinch; ``event.scale`` is relative to activation."""
kind
:
str
=
"pinch"
@
dataclass
(
frozen
=
True
)
class
Rotation
(
_BaseGesture
):
"""Track a two-finger rotation; ``event.rotation`` is in radians."""
kind
:
str
=
"rotation"
GestureSpec
=
_BaseGesture
"""Any gesture descriptor accepted by the ``gestures=`` prop."""
# ======================================================================
# Composition nodes
# ======================================================================
@
dataclass
(
frozen
=
True
)
class
GestureGroup
:
"""A composition node relating child gestures (or nested groups).
Build instances with [`Simultaneous`][pythonnative.gestures.Simultaneous],
[`Race`][pythonnative.gestures.Race], or
[`Exclusive`][pythonnative.gestures.Exclusive] rather than directly.
"""
mode
:
Literal
[
"simultaneous"
,
"race"
,
"exclusive"
]
children
:
Tuple
[
Any
, ...]
=
field
(
default_factory
=
tuple
)
def
Simultaneous
(
*
gestures
:
Any
)
->
GestureGroup
:
"""Compose gestures that may all be active at the same time.
This matches the flat-list default; it exists so simultaneity can
be expressed *inside* [`Race`][pythonnative.gestures.Race] or
[`Exclusive`][pythonnative.gestures.Exclusive] nodes:
```python
gestures.Race(
gestures.Simultaneous(gestures.Pinch(...), gestures.Rotation(...)),
gestures.Pan(...),
)
```
"""
return
GestureGroup
(
"simultaneous"
,
tuple
(
gestures
))
def
Race
(
*
gestures
:
Any
)
->
GestureGroup
:
"""Compose gestures where only the first to activate wins.
As soon as one member activates, every other member fails for the
rest of the interaction (its in-progress recognition is abandoned
without firing callbacks).
"""
return
GestureGroup
(
"race"
,
tuple
(
gestures
))
def
Exclusive
(
*
gestures
:
Any
)
->
GestureGroup
:
"""Compose gestures by priority: earlier members outrank later ones.
A member may only activate once every member listed before it has
*failed*. ``Exclusive(double_tap, single_tap)`` delays the single
tap until the double-tap window has expired, then fires it; if the
second tap lands in time, only the double tap fires.
"""
return
GestureGroup
(
"exclusive"
,
tuple
(
gestures
))
def
serialize_gestures
(
specs
:
Sequence
[
Any
],
)
->
Tuple
[
List
[
Dict
[
str
,
Any
]],
Dict
[
str
,
Callable
[...,
Any
]]]:
"""Flatten gesture descriptors into native config dicts and event routers.
Composition nodes ([`Simultaneous`][pythonnative.gestures.Simultaneous],
[`Race`][pythonnative.gestures.Race],
[`Exclusive`][pythonnative.gestures.Exclusive]) are flattened
depth-first; each resulting spec dict carries the relationship
metadata the recognizers need:
- ``"simultaneous"``: indices this gesture may be active alongside.
- ``"wait_for"``: indices that must *fail* before this gesture may
activate.
Two gestures that are not in each other's ``simultaneous`` sets
race: the first to activate causes the other to fail. Gestures in
the top-level list (outside any composition node) are mutually
simultaneous.
A gesture with ``enabled=False`` keeps its index and its
``"enabled": false`` config so the native side can skip it, but it
is left out of every relationship: nothing waits for it and it
races nobody.
Args:
specs: The value of an element's ``gestures`` prop. Plain dicts
are passed through with relationship metadata attached (no
callbacks to route).
Returns:
``(clean_specs, events)`` where ``clean_specs`` is a list of
JSON-ish config dicts (one per leaf gesture, depth-first) and
``events`` maps ``"gesture:<i>"`` to a router that unpacks the
native payload into a `GestureEvent` and invokes the right
user callback.
"""
leaves
:
List
[
Any
]
=
[]
sim_pairs
:
Set
[
Tuple
[
int
,
int
]]
=
set
()
wait_pairs
:
Set
[
Tuple
[
int
,
int
]]
=
set
()
# (waiter, target)
def
_flatten
(
node
:
Any
)
->
List
[
int
]:
if
isinstance
(
node
,
GestureGroup
):
subtree_leaves
:
List
[
List
[
int
]]
=
[
_flatten
(
child
)
for
child
in
node
.
children
]
for
a_i
in
range
(
len
(
subtree_leaves
)):
for
b_i
in
range
(
a_i
+
1
,
len
(
subtree_leaves
)):
for
a
in
subtree_leaves
[
a_i
]:
for
b
in
subtree_leaves
[
b_i
]:
if
node
.
mode
==
"simultaneous"
:
sim_pairs
.
add
((
a
,
b
))
elif
node
.
mode
==
"exclusive"
:
# Later members wait for earlier ones.
wait_pairs
.
add
((
b
,
a
))
return
[
i
for
group
in
subtree_leaves
for
i
in
group
]
leaves
.
append
(
node
)
return
[
len
(
leaves
)
-
1
]
top_level
:
List
[
List
[
int
]]
=
[
_flatten
(
node
)
for
node
in
specs
]
# Top-level entries are mutually simultaneous (flat-list default).
for
a_i
in
range
(
len
(
top_level
)):
for
b_i
in
range
(
a_i
+
1
,
len
(
top_level
)):
for
a
in
top_level
[
a_i
]:
for
b
in
top_level
[
b_i
]:
sim_pairs
.
add
((
a
,
b
))
def
_enabled
(
leaf
:
Any
)
->
bool
:
if
isinstance
(
leaf
,
_BaseGesture
):
return
bool
(
leaf
.
enabled
)
if
isinstance
(
leaf
,
dict
):
return
leaf
.
get
(
"enabled"
,
True
)
is
not
False
return
True
disabled
=
{
i
for
i
,
leaf
in
enumerate
(
leaves
)
if
not
_enabled
(
leaf
)}
sim_pairs
=
{(
a
,
b
)
for
a
,
b
in
sim_pairs
if
a
not
in
disabled
and
b
not
in
disabled
}
wait_pairs
=
{(
w
,
t
)
for
w
,
t
in
wait_pairs
if
w
not
in
disabled
and
t
not
in
disabled
}
clean
:
List
[
Dict
[
str
,
Any
]]
=
[]
events
:
Dict
[
str
,
Callable
[...,
Any
]]
=
{}
for
i
,
leaf
in
enumerate
(
leaves
):
if
isinstance
(
leaf
,
_BaseGesture
):
spec
=
leaf
.
_to_spec
()
from
.
animated
import
AnimatedEvent
animated
=
{}
for
state
,
callback
in
(
(
"began"
,
leaf
.
on_begin
),
(
"changed"
,
leaf
.
on_change
),
(
"ended"
,
leaf
.
on_end
),
):
if
isinstance
(
callback
,
AnimatedEvent
):
animated
[
state
]
=
{
field
:
id
(
node
)
for
field
,
node
in
callback
.
_bindings
.
items
()}
if
animated
:
spec
[
"animated_events"
]
=
animated
def
_router
(
payload
:
Dict
[
str
,
Any
],
_spec
:
_BaseGesture
=
leaf
)
->
None
:
_spec
.
_dispatch
(
event_from_payload
(
payload
))
events
[
f"gesture:
{
i
}
"
]
=
_router
elif
isinstance
(
leaf
,
dict
):
spec
=
dict
(
leaf
)
else
:
spec
=
{
"kind"
:
""
}
spec
[
"simultaneous"
]
=
sorted
({
b
for
a
,
b
in
sim_pairs
if
a
==
i
}
|
{
a
for
a
,
b
in
sim_pairs
if
b
==
i
})
spec
[
"wait_for"
]
=
sorted
({
t
for
w
,
t
in
wait_pairs
if
w
==
i
})
clean
.
append
(
spec
)
return
clean
,
events
# ======================================================================
# Pure-Python recognition engine (browser preview + reference semantics)
# ======================================================================
#
# iOS and Android recognize natively. The browser preview receives raw
# DOM pointer streams instead, which this arbiter turns into the same
# GestureEvent payloads. Keeping it in pure Python makes the state
# machines unit-testable with scripted event sequences; the Kotlin
# arbiter mirrors this file so the two stay in lockstep.
EmitFn
=
Callable
[[
int
,
Dict
[
str
,
Any
]],
None
]
"""``emit(gesture_index, payload)``: the arbiter's output channel."""
# Internal (never user-visible) state used by recognizers to tell the
# arbiter they can no longer succeed for this interaction.
_FAILED
=
"__failed"
class
_VelocityTracker
:
"""Estimate pointer velocity from recent samples (points/second)."""
__slots__
=
(
"_samples"
,)
_WINDOW_S
=
0.1
def
__init__
(
self
)
->
None
:
self
.
_samples
:
List
[
Tuple
[
float
,
float
,
float
]]
=
[]
def
add
(
self
,
x
:
float
,
y
:
float
,
t
:
float
)
->
None
:
self
.
_samples
.
append
((
x
,
y
,
t
))
cutoff
=
t
-
self
.
_WINDOW_S
while
len
(
self
.
_samples
)
>
2
and
self
.
_samples
[
0
][
2
]
<
cutoff
:
self
.
_samples
.
pop
(
0
)
def
velocity
(
self
)
->
Tuple
[
float
,
float
]:
if
len
(
self
.
_samples
)
<
2
:
return
(
0.0
,
0.0
)
x0
,
y0
,
t0
=
self
.
_samples
[
0
]
x1
,
y1
,
t1
=
self
.
_samples
[
-
1
]
dt
=
t1
-
t0
if
dt
<=
1e-6
:
return
(
0.0
,
0.0
)
return
((
x1
-
x0
)
/
dt
, (
y1
-
y0
)
/
dt
)
def
reset
(
self
)
->
None
:
self
.
_samples
.
clear
()
class
_Recognizer
:
"""Base class for one gesture's state machine."""
def
__init__
(
self
,
index
:
int
,
config
:
Dict
[
str
,
Any
],
emit
:
EmitFn
)
->
None
:
self
.
index
=
index
self
.
config
=
config
self
.
_emit_fn
=
emit
def
emit
(
self
,
state
:
GestureState
,
**
fields
:
Any
)
->
None
:
payload
:
Dict
[
str
,
Any
]
=
{
"kind"
:
self
.
kind
(),
"state"
:
state
.
value
}
payload
.
update
(
fields
)
self
.
_emit_fn
(
self
.
index
,
payload
)
def
fail
(
self
)
->
None
:
"""Report that this gesture can no longer succeed this interaction."""
self
.
_emit_fn
(
self
.
index
, {
"kind"
:
self
.
kind
(),
"state"
:
_FAILED
})
def
force_fail
(
self
,
t
:
float
)
->
None
:
"""Abandon recognition without emitting anything (lost a race)."""
self
.
cancel
(
t
)
def
kind
(
self
)
->
str
:
return
str
(
self
.
config
.
get
(
"kind"
,
""
))
# Event hooks: ``pointers`` maps pointer id -> (x, y).
def
down
(
self
,
pointers
:
Dict
[
int
,
Tuple
[
float
,
float
]],
t
:
float
)
->
None
:
pass
def
move
(
self
,
pointers
:
Dict
[
int
,
Tuple
[
float
,
float
]],
t
:
float
)
->
None
:
pass
def
up
(
self
,
pointers
:
Dict
[
int
,
Tuple
[
float
,
float
]],
t
:
float
,
x
:
float
,
y
:
float
)
->
None
:
pass
def
cancel
(
self
,
t
:
float
)
->
None
:
pass
def
deadline
(
self
)
->
Optional
[
float
]:
"""Next time `poll` should run, or ``None``."""
return
None
def
poll
(
self
,
t
:
float
)
->
None
:
pass
def
_centroid
(
pointers
:
Dict
[
int
,
Tuple
[
float
,
float
]])
->
Tuple
[
float
,
float
]:
if
not
pointers
:
return
(
0.0
,
0.0
)
xs
=
sum
(
p
[
0
]
for
p
in
pointers
.
values
())
ys
=
sum
(
p
[
1
]
for
p
in
pointers
.
values
())
n
=
len
(
pointers
)
return
(
xs
/
n
,
ys
/
n
)
class
_TapRecognizer
(
_Recognizer
):
def
__init__
(
self
,
index
:
int
,
config
:
Dict
[
str
,
Any
],
emit
:
EmitFn
)
->
None
:
super
().
__init__
(
index
,
config
,
emit
)
self
.
_n_taps
=
max
(
1
,
int
(
config
.
get
(
"n_taps"
,
1
)))
self
.
_slop
=
float
(
config
.
get
(
"max_distance"
,
12.0
))
self
.
_down_pos
:
Optional
[
Tuple
[
float
,
float
]]
=
None
self
.
_down_time
=
0.0
self
.
_tap_count
=
0
self
.
_last_tap_time
=
0.0
self
.
_failed
=
False
# Deadline for the *next* tap of a multi-tap (or None).
self
.
_gap_deadline
:
Optional
[
float
]
=
None
_MAX_TAP_DURATION_S
=
0.4
_MULTI_TAP_GAP_S
=
0.3
def
down
(
self
,
pointers
:
Dict
[
int
,
Tuple
[
float
,
float
]],
t
:
float
)
->
None
:
if
len
(
pointers
)
!=
1
:
if
not
self
.
_failed
:
self
.
_failed
=
True
self
.
fail
()
return
if
self
.
_tap_count
>
0
and
t
-
self
.
_last_tap_time
>
self
.
_MULTI_TAP_GAP_S
:
self
.
_tap_count
=
0
self
.
_failed
=
False
self
.
_gap_deadline
=
None
self
.
_down_pos
=
_centroid
(
pointers
)
self
.
_down_time
=
t
def
move
(
self
,
pointers
:
Dict
[
int
,
Tuple
[
float
,
float
]],
t
:
float
)
->
None
:
if
self
.
_failed
or
self
.
_down_pos
is
None
:
return
x
,
y
=
_centroid
(
pointers
)
if
math
.
hypot
(
x
-
self
.
_down_pos
[
0
],
y
-
self
.
_down_pos
[
1
])
>
self
.
_slop
:
self
.
_failed
=
True
self
.
fail
()
def
up
(
self
,
pointers
:
Dict
[
int
,
Tuple
[
float
,
float
]],
t
:
float
,
x
:
float
,
y
:
float
)
->
None
:
if
self
.
_failed
or
self
.
_down_pos
is
None
:
self
.
_reset
()
return
if
t
-
self
.
_down_time
>
self
.
_MAX_TAP_DURATION_S
:
self
.
_reset
()
self
.
fail
()
return
self
.
_tap_count
+=
1
self
.
_last_tap_time
=
t
if
self
.
_tap_count
>=
self
.
_n_taps
:
self
.
emit
(
GestureState
.
ENDED
,
x
=
x
,
y
=
y
)
self
.
_reset
()
else
:
# Waiting for the next tap; fail if it never arrives so
# gestures waiting on this one (Exclusive) can proceed.
self
.
_gap_deadline
=
t
+
self
.
_MULTI_TAP_GAP_S
self
.
_down_pos
=
None
def
cancel
(
self
,
t
:
float
)
->
None
:
self
.
_reset
()
def
deadline
(
self
)
->
Optional
[
float
]:
return
self
.
_gap_deadline
def
poll
(
self
,
t
:
float
)
->
None
:
if
self
.
_gap_deadline
is
not
None
and
t
>=
self
.
_gap_deadline
:
self
.
_gap_deadline
=
None
self
.
_tap_count
=
0
self
.
fail
()
def
_reset
(
self
)
->
None
:
self
.
_down_pos
=
None
self
.
_tap_count
=
0
if
self
.
_tap_count
>=
self
.
_n_taps
else
self
.
_tap_count
self
.
_failed
=
False
self
.
_gap_deadline
=
None
class
_LongPressRecognizer
(
_Recognizer
):
def
__init__
(
self
,
index
:
int
,
config
:
Dict
[
str
,
Any
],
emit
:
EmitFn
)
->
None
:
super
().
__init__
(
index
,
config
,
emit
)
self
.
_duration_s
=
float
(
config
.
get
(
"min_duration_ms"
,
500.0
))
/
1000.0
self
.
_slop
=
float
(
config
.
get
(
"max_distance"
,
12.0
))
self
.
_down_pos
:
Optional
[
Tuple
[
float
,
float
]]
=
None
self
.
_deadline
:
Optional
[
float
]
=
None
self
.
_active
=
False
def
down
(
self
,
pointers
:
Dict
[
int
,
Tuple
[
float
,
float
]],
t
:
float
)
->
None
:
self
.
_down_pos
=
_centroid
(
pointers
)
self
.
_deadline
=
t
+
self
.
_duration_s
self
.
_active
=
False
def
move
(
self
,
pointers
:
Dict
[
int
,
Tuple
[
float
,
float
]],
t
:
float
)
->
None
:
if
self
.
_down_pos
is
None
:
return
x
,
y
=
_centroid
(
pointers
)
if
math
.
hypot
(
x
-
self
.
_down_pos
[
0
],
y
-
self
.
_down_pos
[
1
])
>
self
.
_slop
:
if
self
.
_active
:
self
.
emit
(
GestureState
.
CANCELLED
,
x
=
x
,
y
=
y
)
self
.
_reset
()
self
.
fail
()
def
up
(
self
,
pointers
:
Dict
[
int
,
Tuple
[
float
,
float
]],
t
:
float
,
x
:
float
,
y
:
float
)
->
None
:
if
self
.
_active
:
self
.
emit
(
GestureState
.
ENDED
,
x
=
x
,
y
=
y
)
elif
self
.
_down_pos
is
not
None
:
self
.
fail
()
self
.
_reset
()
def
cancel
(
self
,
t
:
float
)
->
None
:
if
self
.
_active
:
self
.
emit
(
GestureState
.
CANCELLED
)
self
.
_reset
()
def
deadline
(
self
)
->
Optional
[
float
]:
return
self
.
_deadline
def
poll
(
self
,
t
:
float
)
->
None
:
if
self
.
_deadline
is
None
or
self
.
_down_pos
is
None
or
self
.
_active
:
return
if
t
>=
self
.
_deadline
:
self
.
_active
=
True
self
.
_deadline
=
None
self
.
emit
(
GestureState
.
BEGAN
,
x
=
self
.
_down_pos
[
0
],
y
=
self
.
_down_pos
[
1
])
def
_reset
(
self
)
->
None
:
self
.
_down_pos
=
None
self
.
_deadline
=
None
self
.
_active
=
False
class
_PanRecognizer
(
_Recognizer
):
"""Pan state machine implementing the activation rules documented on ``Pan``."""
def
__init__
(
self
,
index
:
int
,
config
:
Dict
[
str
,
Any
],
emit
:
EmitFn
)
->
None
:
super
().
__init__
(
index
,
config
,
emit
)
self
.
_min_pointers
=
max
(
1
,
int
(
config
.
get
(
"min_pointers"
,
1
)))
max_pointers
=
config
.
get
(
"max_pointers"
)
self
.
_max_pointers
:
Optional
[
int
]
=
None
if
max_pointers
is
None
else
int
(
max_pointers
)
self
.
_active_x
=
_normalize_offset
(
"active_offset_x"
,
config
.
get
(
"active_offset_x"
))
self
.
_active_y
=
_normalize_offset
(
"active_offset_y"
,
config
.
get
(
"active_offset_y"
))
self
.
_fail_x
=
_normalize_offset
(
"fail_offset_x"
,
config
.
get
(
"fail_offset_x"
))
self
.
_fail_y
=
_normalize_offset
(
"fail_offset_y"
,
config
.
get
(
"fail_offset_y"
))
min_velocity
=
config
.
get
(
"min_velocity"
)
self
.
_min_velocity
:
Optional
[
float
]
=
None
if
min_velocity
is
None
else
float
(
min_velocity
)
custom
=
(
any
(
b
is
not
None
for
b
in
(
self
.
_active_x
,
self
.
_active_y
,
self
.
_fail_x
,
self
.
_fail_y
))
View remainder of file in raw view
Back
|
FazBrowse Home
|
New Git URL