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.
- pythonnative/__init__.py +14 -3
- pythonnative/animated.py +420 -135
- pythonnative/components.py +519 -235
- pythonnative/events.py +210 -0
- pythonnative/gestures.py +875 -0
- pythonnative/layout.py +463 -149
- pythonnative/mutations.py +130 -0
- pythonnative/native_views/__init__.py +161 -97
- pythonnative/native_views/android.py +1048 -1142
- pythonnative/native_views/base.py +108 -18
- pythonnative/native_views/desktop.py +460 -417
- pythonnative/native_views/ios.py +1918 -1916
- pythonnative/project/android.py +2 -2
- pythonnative/reconciler.py +540 -470
- pythonnative/screen.py +5 -2
- pythonnative/sdk/_components.py +2 -2
- pythonnative/templates/android_template/app/build.gradle +2 -0
- {pythonnative-0.21.0.dist-info → pythonnative-0.22.0.dist-info}/METADATA +5 -2
- {pythonnative-0.21.0.dist-info → pythonnative-0.22.0.dist-info}/RECORD +23 -21
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +0 -129
- {pythonnative-0.21.0.dist-info → pythonnative-0.22.0.dist-info}/WHEEL +0 -0
- {pythonnative-0.21.0.dist-info → pythonnative-0.22.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.21.0.dist-info → pythonnative-0.22.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.21.0.dist-info → pythonnative-0.22.0.dist-info}/top_level.txt +0 -0
pythonnative/gestures.py
ADDED
|
@@ -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)
|