Touch Gestures¶

tap, long press, swipe, pinch, rotate, pan.

â–¶ Run in browser

Tags: 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¶

  • GestureRecognizer as 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()