Touch Gestures¶
tap, long press, swipe, pinch, rotate, pan.
â–¶ Run in browserTags: ui input touch gestures mobile
A GestureRecognizer turns raw touch points into high-level gestures and emits
one signal per kind. Mouse-to-touch emulation makes every single-finger gesture
reachable on desktop: tap for a flash at the touch point, drag to pan the blue
square, and long-press or flick to fill the rolling event log. Multi-touch
hardware adds pinch (which scales the circle it is centred on) and rotate.
What it demonstrates¶
GestureRecognizeras a child node, with one signal per gesture kind (tap,long_press,swipe,pinch,rotate,pan) and their arguments.Input.set_touch_emulation(True)so mouse input drives the same pipeline.Tuning a recogniser threshold (
swipe_min_velocity) away from its default.Timing gesture feedback off the engine clock (
tree.now), which is the clock the recogniser itself times against, so pause and slow motion reach both.
Controls: Mouse/Touch - Perform gestures Escape - Quit
Usage: uv run python examples/features/ui/gestures.py uv run python examples/features/ui/gestures.py –test
Source¶
1"""Touch Gestures: tap, long press, swipe, pinch, rotate, pan.
2
3A `GestureRecognizer` turns raw touch points into high-level gestures and emits
4one signal per kind. Mouse-to-touch emulation makes every single-finger gesture
5reachable on desktop: tap for a flash at the touch point, drag to pan the blue
6square, and long-press or flick to fill the rolling event log. Multi-touch
7hardware adds pinch (which scales the circle it is centred on) and rotate.
8
9# /// simvx
10# tags = ["ui", "input", "touch", "gestures", "mobile"]
11# web = { root = "GestureDemo", width = 1024, height = 768, responsive = true }
12# ///
13
14## What it demonstrates
15- `GestureRecognizer` as a child node, with one signal per gesture kind
16 (`tap`, `long_press`, `swipe`, `pinch`, `rotate`, `pan`) and their arguments.
17- `Input.set_touch_emulation(True)` so mouse input drives the same pipeline.
18- Tuning a recogniser threshold (`swipe_min_velocity`) away from its default.
19- Timing gesture feedback off the engine clock (`tree.now`), which is the clock
20 the recogniser itself times against, so pause and slow motion reach both.
21
22Controls:
23 Mouse/Touch - Perform gestures
24 Escape - Quit
25
26Usage:
27 uv run python examples/features/ui/gestures.py
28 uv run python examples/features/ui/gestures.py --test
29"""
30
31import math
32
33from simvx.core import GestureRecognizer, Input, Key, Node2D, Vec2
34from simvx.graphics import App
35
36WIDTH, HEIGHT = 1024, 768
37MAX_LOG = 10
38
39
40class GestureDemo(Node2D):
41 """Root scene demonstrating gesture recognition with visual feedback."""
42
43 dynamic = True # flash fade, banner timeout and log all animate every frame
44
45 input_actions = {"quit": [Key.ESCAPE]}
46
47 def on_ready(self):
48 # Enable mouse-to-touch emulation so gestures work on desktop
49 Input.set_touch_emulation(True)
50
51 # Gesture recognizer
52 self._gesture = self.add_child(GestureRecognizer(name="Gestures"))
53 # Thresholds are tunable: tap_timeout, long_press_timeout,
54 # tap_max_distance and swipe_min_velocity. Only the swipe threshold is
55 # changed here (the default is 500 px/s), which makes flicks easier.
56 self._gesture.swipe_min_velocity = 400.0
57
58 # Connect gesture signals
59 self._gesture.tap.connect(self._on_tap)
60 self._gesture.long_press.connect(self._on_long_press)
61 self._gesture.swipe.connect(self._on_swipe)
62 self._gesture.pinch.connect(self._on_pinch)
63 self._gesture.rotate.connect(self._on_rotate)
64 self._gesture.pan.connect(self._on_pan)
65
66 # Draggable square state
67 self._square_pos = Vec2(self.app.width * 0.3, self.app.height * 0.5)
68 self._square_size = 80.0
69
70 # Scalable circle state (recentred from the live window size each frame)
71 self._circle_pos = Vec2(self.app.width * 0.7, self.app.height * 0.5)
72 self._circle_radius = 60.0
73
74 # Visual feedback
75 self._last_gesture = ""
76 self._last_gesture_time = 0.0
77 self._log: list[str] = []
78
79 # Tap flash
80 self._tap_pos: Vec2 | None = None
81 self._tap_flash = 0.0
82
83 def _log_event(self, msg: str):
84 self._log.append(msg)
85 if len(self._log) > MAX_LOG:
86 self._log.pop(0)
87 self._last_gesture = msg
88 self._last_gesture_time = self.tree.now
89
90 def _on_tap(self, x: float, y: float):
91 self._tap_pos = Vec2(x, y)
92 self._tap_flash = 0.3
93 self._log_event(f"tap ({x:.0f}, {y:.0f})")
94
95 def _on_long_press(self, x: float, y: float):
96 self._log_event(f"long_press ({x:.0f}, {y:.0f})")
97
98 def _on_swipe(self, direction: str):
99 self._log_event(f"swipe {direction}")
100
101 def _on_pinch(self, scale: float, cx: float, cy: float):
102 # Scale the circle if pinch is near it
103 dx = cx - self._circle_pos.x
104 dy = cy - self._circle_pos.y
105 if math.sqrt(dx * dx + dy * dy) < self._circle_radius + 100:
106 self._circle_radius = max(20.0, min(200.0, self._circle_radius * scale))
107 self._log_event(f"pinch scale={scale:.2f}")
108
109 def _on_rotate(self, angle_delta: float, cx: float, cy: float):
110 self._log_event(f"rotate {math.degrees(angle_delta):.1f} deg")
111
112 def _on_pan(self, dx: float, dy: float):
113 self._square_pos = Vec2(
114 max(0, min(self.app.width, self._square_pos.x + dx)),
115 max(0, min(self.app.height, self._square_pos.y + dy)),
116 )
117 self._log_event(f"pan ({dx:.0f}, {dy:.0f})")
118
119 def on_update(self, dt: float):
120 if Input.is_action_just_pressed("quit"):
121 self.app.quit()
122 if self._tap_flash > 0:
123 self._tap_flash -= dt
124 self._circle_pos = Vec2(self.app.width * 0.7, self.app.height * 0.5)
125
126 def on_draw(self, renderer):
127 w, h = self.app.width, self.app.height
128
129 # Background
130 renderer.draw_rect((0, 0), (w, h), colour=(0.08, 0.08, 0.12, 1.0), filled=True)
131
132 # Draggable square
133 half = self._square_size / 2
134 sx, sy = self._square_pos.x - half, self._square_pos.y - half
135 renderer.draw_rect(
136 (sx, sy), (self._square_size, self._square_size), colour=(0.24, 0.47, 0.78, 1.0), filled=True
137 )
138 renderer.draw_text("drag me", (sx + 8, sy + 35), colour=(0.78, 0.86, 1.0), scale=2)
139
140 # Scalable circle
141 r = int(self._circle_radius)
142 renderer.draw_circle(self._circle_pos, r, colour=(0.78, 0.31, 0.47, 1.0), segments=32, filled=True)
143 renderer.draw_text(
144 "pinch", (self._circle_pos.x - 18, self._circle_pos.y - 6), colour=(1.0, 0.78, 0.86), scale=2
145 )
146
147 # Tap flash indicator
148 if self._tap_flash > 0 and self._tap_pos is not None:
149 a = self._tap_flash / 0.3
150 renderer.draw_circle(self._tap_pos, 20, colour=(1.0, 1.0, 0.4, a), segments=16, filled=True)
151
152 # HUD
153 renderer.draw_text("GESTURE RECOGNITION DEMO", (10, 10), colour=(0.78, 0.78, 0.78), scale=2)
154 renderer.draw_text(
155 "Mouse: tap, drag, long-press | Multitouch: pinch, rotate", (10, 35), colour=(0.59, 0.59, 0.59)
156 )
157
158 # Current gesture
159 age = self.tree.now - self._last_gesture_time if self._last_gesture else 999
160 if age < 2.0:
161 renderer.draw_text(f">> {self._last_gesture}", (10, 60), scale=2, colour=(0.39, 1.0, 0.39))
162
163 # Event log
164 renderer.draw_text("Recent gestures:", (10, h - 230), scale=2, colour=(0.71, 0.71, 0.71))
165 for i, entry in enumerate(reversed(self._log)):
166 y = h - 210 + i * 18
167 fade = max(0.31, 0.86 - i * 0.06)
168 renderer.draw_text(entry, (20, y), scale=2, colour=(fade, fade, fade))
169
170
171def _selftest() -> bool:
172 """Headless: perform each gesture for real and read the log the demo keeps.
173
174 Nothing calls a gesture handler: a tap is a mouse press and release, a pan is
175 a press and a move, and pinch and rotate are two fingers put down and moved.
176 The recogniser times its gestures off the scene clock, which a headless run
177 advances by one 60th of a second per frame, so a hold is a number of frames
178 rather than a real wait and the run is reproducible.
179 """
180 from simvx.core import MouseButton
181 from simvx.core.testing import InputSimulator
182 from simvx.graphics.testing import assert_not_blank, save_png
183
184 app = App(width=WIDTH, height=HEIGHT, title="Gesture Recognition Demo", visible=False)
185 scene = GestureDemo(name="GestureDemo")
186 sim = InputSimulator()
187 seen: dict[str, object] = {}
188
189 # A headless frame is 1/60 s of scene time, so the hold that makes a long
190 # press is long_press_timeout * 60 frames with a margin on top.
191 HOLD_FRAMES = int(GestureRecognizer.long_press_timeout.default * 60) + 4
192 TAP, TAP_END = 4, 5
193 HOLD = 12
194 HOLD_END = HOLD + HOLD_FRAMES
195 SWIPE, SWIPE_MOVE, SWIPE_END = HOLD_END + 6, HOLD_END + 7, HOLD_END + 8
196 PAN, PAN_MOVE, PAN_END = SWIPE_END + 6, SWIPE_END + 7, SWIPE_END + 8
197 PINCH, PINCH_SPREAD, PINCH_END = PAN_END + 6, PAN_END + 7, PAN_END + 8
198 ROTATE, ROTATE_TURN, ROTATE_END = PINCH_END + 6, PINCH_END + 7, PINCH_END + 8
199 FRAMES = ROTATE_END + 6
200 TAP_AT, PAN_FROM, PAN_BY = (300.0, 400.0), (300.0, 300.0), 60.0
201 SWIPE_FROM, SWIPE_TO = (200.0, 400.0), (500.0, 400.0)
202
203 def on_frame(idx: int, _t: float) -> bool:
204 if idx == 0:
205 seen["square_start"] = float(scene._square_pos.x)
206 seen["circle_start"] = float(scene._circle_radius)
207 elif idx == TAP:
208 sim.press_mouse(MouseButton.LEFT, TAP_AT)
209 elif idx == TAP_END:
210 sim.release_mouse(MouseButton.LEFT)
211 elif idx == TAP + 2:
212 pos = scene._tap_pos
213 seen["tap_flash"] = (scene._tap_flash, None if pos is None else (float(pos.x), float(pos.y)))
214 elif idx == HOLD:
215 # Pressed here and released HOLD_FRAMES later: the finger is still
216 # down for every frame between, which is the hold a long press is.
217 sim.press_mouse(MouseButton.LEFT, TAP_AT)
218 elif idx == HOLD_END:
219 sim.release_mouse(MouseButton.LEFT)
220 elif idx == SWIPE:
221 sim.press_mouse(MouseButton.LEFT, SWIPE_FROM)
222 elif idx == SWIPE_MOVE:
223 sim.move_mouse(*SWIPE_TO) # a flick: 300px in the one frame before the release
224 elif idx == SWIPE_END:
225 sim.release_mouse(MouseButton.LEFT)
226 elif idx == PAN:
227 sim.press_mouse(MouseButton.LEFT, PAN_FROM)
228 seen["square_before_pan"] = float(scene._square_pos.x)
229 elif idx == PAN_MOVE:
230 sim.move_mouse(PAN_FROM[0] + PAN_BY, PAN_FROM[1])
231 elif idx == PAN_END:
232 sim.release_mouse(MouseButton.LEFT)
233 seen["square_after_pan"] = float(scene._square_pos.x)
234 elif idx == PINCH:
235 # Two fingers straddling the circle, so the spread scales it.
236 cx, cy = float(scene._circle_pos.x), float(scene._circle_pos.y)
237 seen["pinch_centre"] = (cx, cy)
238 sim.touch_down(0, (cx - 50, cy))
239 sim.touch_down(1, (cx + 50, cy))
240 elif idx == PINCH_SPREAD:
241 cx, cy = seen["pinch_centre"]
242 sim.touch_move(0, (cx - 100, cy))
243 sim.touch_move(1, (cx + 100, cy))
244 elif idx == PINCH_END:
245 cx, cy = seen["pinch_centre"]
246 seen["circle_after_pinch"] = float(scene._circle_radius)
247 sim.touch_up(0, (cx - 100, cy))
248 sim.touch_up(1, (cx + 100, cy))
249 elif idx == ROTATE:
250 cx, cy = seen["pinch_centre"]
251 sim.touch_down(0, (cx - 80, cy))
252 sim.touch_down(1, (cx + 80, cy))
253 elif idx == ROTATE_TURN:
254 cx, cy = seen["pinch_centre"]
255 sim.touch_move(0, (cx, cy - 80)) # a quarter turn of the finger pair
256 sim.touch_move(1, (cx, cy + 80))
257 elif idx == ROTATE_END:
258 cx, cy = seen["pinch_centre"]
259 sim.touch_up(0, (cx, cy - 80))
260 sim.touch_up(1, (cx, cy + 80))
261 seen["log"] = list(scene._log)
262 return True
263
264 frames = app.run_headless(scene, frames=FRAMES, on_frame=on_frame, capture_frames=[FRAMES - 1])
265 assert_not_blank(frames[0])
266 save_png(frames[0], "/tmp/gestures_test.png")
267
268 ok = True
269
270 def check(label: str, passed: bool, detail: str) -> None:
271 nonlocal ok
272 ok = ok and passed
273 print(f"{'ok ' if passed else 'FAIL'} {label}: {detail}")
274
275 log = seen["log"]
276 kinds = [entry.split()[0].split("(")[0] for entry in log]
277
278 def logged(kind: str) -> str:
279 return next((entry for entry in log if entry.startswith(kind)), "")
280
281 check(
282 "a press and release is read as a tap, at the point it happened",
283 logged("tap") == f"tap ({TAP_AT[0]:.0f}, {TAP_AT[1]:.0f})",
284 logged("tap") or f"only {set(kinds)} were recognised",
285 )
286 flash, at = seen["tap_flash"]
287 check(
288 "and the tap flashes where it landed",
289 flash > 0.0 and at == TAP_AT,
290 f"flash {flash:.2f}s at {at}",
291 )
292 check(
293 "holding still for longer than the timeout is a long press",
294 bool(logged("long_press")),
295 logged("long_press") or "no long_press in the log",
296 )
297 check(
298 "a fast flick to the right is a rightward swipe",
299 logged("swipe") == "swipe right",
300 logged("swipe") or "no swipe in the log",
301 )
302 check(
303 "dragging pans the square by the distance the finger moved",
304 abs((seen["square_after_pan"] - seen["square_before_pan"]) - PAN_BY) < 0.01,
305 f"square moved {seen['square_after_pan'] - seen['square_before_pan']:.1f}px for a {PAN_BY:.0f}px drag",
306 )
307 check(
308 "spreading two fingers over the circle pinches it larger",
309 seen["circle_after_pinch"] > seen["circle_start"] and bool(logged("pinch")),
310 f"radius {seen['circle_start']:.0f} -> {seen['circle_after_pinch']:.0f} ({logged('pinch')})",
311 )
312 check(
313 "turning the finger pair is read as a rotation",
314 bool(logged("rotate")),
315 logged("rotate") or "no rotate in the log",
316 )
317 check(
318 "the log keeps only the most recent entries",
319 len(log) <= MAX_LOG,
320 f"{len(log)} entries, capped at {MAX_LOG}",
321 )
322
323 print("screenshot: /tmp/gestures_test.png")
324 print("SELFTEST:", "PASS" if ok else "FAIL")
325 return ok
326
327
328def main():
329 app = App(width=WIDTH, height=HEIGHT, title="Gesture Recognition Demo")
330 app.run(GestureDemo())
331
332
333if __name__ == "__main__":
334 import sys
335
336 if "--test" in sys.argv:
337 sys.exit(0 if _selftest() else 1)
338 main()