Gestures as controls

pinch, twist and pan a map, long-press to drop a pin

▶ Run in browser

Tags: input touch gestures mouse mobile

A map you move with two fingers or a mouse. Every gesture is a control bound in the node’s input_context exactly as a key is: zoom takes Touch.PINCH and Mouse.WHEEL * 0.1, twist takes Touch.ROTATE and pan the stick Touch.PAN. Input.touch_secondary = Touch.LONG_PRESS makes a long press the finger’s right click, so drop bound to Pointer.SECONDARY drops a pin under a right click and under a held finger alike, and Touch.TWO_FINGER_TAP resets the view.

What it demonstrates

  • Touch.PINCH, Touch.ROTATE and Mouse.WHEEL as axes, read with Input.value: each is the frame’s motion and rests at zero.

  • A scaled binding: Mouse.WHEEL * 0.1 puts a wheel notch in the pinch’s unit, so one zoom action serves both.

  • Touch.PAN as a stick, bound once into a Vec2 action.

  • Touch.TWO_FINGER_TAP as a button.

  • Input.touch_secondary: a long press joins Pointer.SECONDARY, so one binding serves the mouse’s right button and a finger held still.

  • Recognition costs nothing unless something names a gesture: this scene names them, so it pays for them.

Controls: Pinch / mouse wheel - Zoom Twist two fingers / Q, E - Rotate Two-finger drag / left drag - Pan Long press / right click - Drop a pin Two-finger tap / R - Reset the view ESC - Quit

Run: simvx examples run features_input_touch_gestures Headless self-check: simvx examples run features_input_touch_gestures –test

Source

  1# /// simvx
  2# tags = ["input", "touch", "gestures", "mouse", "mobile"]
  3# web = { width = 960, height = 600, responsive = true }
  4# ///
  5"""Gestures as controls: pinch, twist and pan a map, long-press to drop a pin
  6
  7A map you move with two fingers or a mouse. Every gesture is a control bound in
  8the node's ``input_context`` exactly as a key is: ``zoom`` takes ``Touch.PINCH`` and
  9``Mouse.WHEEL * 0.1``, ``twist`` takes ``Touch.ROTATE`` and ``pan`` the
 10stick ``Touch.PAN``. ``Input.touch_secondary = Touch.LONG_PRESS`` makes a long press the
 11finger's right click, so ``drop`` bound to ``Pointer.SECONDARY`` drops a pin under
 12a right click and under a held finger alike, and ``Touch.TWO_FINGER_TAP`` resets
 13the view.
 14
 15## What it demonstrates
 16- ``Touch.PINCH``, ``Touch.ROTATE`` and ``Mouse.WHEEL`` as axes, read with
 17  ``Input.value``: each is the frame's motion and rests at zero.
 18- A scaled binding: ``Mouse.WHEEL * 0.1`` puts a wheel notch in the pinch's
 19  unit, so one ``zoom`` action serves both.
 20- ``Touch.PAN`` as a stick, bound once into a ``Vec2`` action.
 21- ``Touch.TWO_FINGER_TAP`` as a button.
 22- ``Input.touch_secondary``: a long press joins ``Pointer.SECONDARY``, so one
 23  binding serves the mouse's right button and a finger held still.
 24- Recognition costs nothing unless something names a gesture: this scene
 25  names them, so it pays for them.
 26
 27Controls:
 28  Pinch / mouse wheel           - Zoom
 29  Twist two fingers / Q, E      - Rotate
 30  Two-finger drag / left drag   - Pan
 31  Long press / right click      - Drop a pin
 32  Two-finger tap / R            - Reset the view
 33  ESC                           - Quit
 34
 35Run: simvx examples run features_input_touch_gestures
 36Headless self-check: simvx examples run features_input_touch_gestures --test
 37"""
 38
 39import math
 40
 41from simvx.core import (
 42    Axis1D,
 43    Input,
 44    Key,
 45    Mouse,
 46    Node2D,
 47    Pointer,
 48    PointerEvent,
 49    PointerPhase,
 50    PointerType,
 51    Touch,
 52    Vec2,
 53    on_input,
 54)
 55from simvx.graphics import App
 56
 57WINDOW_W, WINDOW_H = 960, 600
 58TILE = 80.0
 59TILES = 12
 60
 61
 62class GestureMap(Node2D):
 63    """A tiled map with pins, zoomed, turned and panned by gestures."""
 64
 65    dynamic = True
 66    input_context = {
 67        "zoom": [Touch.PINCH, Mouse.WHEEL * 0.1],
 68        "twist": [Touch.ROTATE],
 69        "turn": [Axis1D(low=Key.Q, high=Key.E)],
 70        "pan": [Touch.PAN],
 71        "drop": [Pointer.SECONDARY],
 72        "reset": [Touch.TWO_FINGER_TAP, Key.R],
 73        "quit": [Key.ESCAPE],
 74    }
 75
 76    def __init__(self, **kwargs):
 77        super().__init__(**kwargs)
 78        self.zoom = 1.0
 79        self.angle = 0.0
 80        self.offset = Vec2(0.0, 0.0)
 81        self.pins: list[Vec2] = []
 82        self._dragging = False
 83
 84    def on_ready(self):
 85        # A finger held still is this game's right click.
 86        Input.touch_secondary = Touch.LONG_PRESS
 87
 88    def on_update(self, dt):
 89        # Zoom is the frame's fractional change of scale (a wheel notch is scaled
 90        # into that unit), a twist the frame's turn in radians, the keys a held direction.
 91        self.zoom *= 1.0 + Input.value("zoom")
 92        self.zoom = max(0.25, min(4.0, self.zoom))
 93        self.angle += Input.value("twist") + Input.value("turn") * 2.0 * dt
 94        self.offset += Input.value("pan")
 95        if Input.just_pressed("drop"):
 96            self.pins.append(self._to_map(Input.mouse_position))
 97        if Input.just_pressed("reset"):
 98            self.zoom, self.angle, self.offset = 1.0, 0.0, Vec2(0.0, 0.0)
 99        if Input.just_pressed("quit"):
100            self.app.quit()
101
102    @on_input(PointerEvent)
103    def _drag(self, event):
104        """A left drag pans too, so a mouse reaches everything a hand does."""
105        if event.pointer_type is not PointerType.MOUSE:
106            return None
107        if event.phase is PointerPhase.DOWN and event.button is Mouse.LEFT:
108            self._dragging = True
109        elif event.phase is PointerPhase.UP and event.button is Mouse.LEFT:
110            self._dragging = False
111        elif event.phase is PointerPhase.MOVE and self._dragging:
112            self.offset += Vec2(*event.delta)
113        return None
114
115    def _centre(self) -> Vec2:
116        size = self.viewport_size
117        return Vec2(size.x / 2, size.y / 2) + self.offset
118
119    def _to_screen(self, point: Vec2) -> Vec2:
120        c, s = math.cos(self.angle), math.sin(self.angle)
121        x, y = point.x * self.zoom, point.y * self.zoom
122        return self._centre() + Vec2(x * c - y * s, x * s + y * c)
123
124    def _to_map(self, screen) -> Vec2:
125        d = Vec2(screen[0], screen[1]) - self._centre()
126        c, s = math.cos(-self.angle), math.sin(-self.angle)
127        return Vec2((d.x * c - d.y * s) / self.zoom, (d.x * s + d.y * c) / self.zoom)
128
129    def on_draw(self, renderer):
130        size = self.viewport_size
131        renderer.rect((0, 0), (size.x, size.y), fill=(0.08, 0.1, 0.14, 1.0))
132        half = TILES * TILE / 2
133        grid = (0.3, 0.45, 0.6, 1.0)
134        for i in range(TILES + 1):
135            t = -half + i * TILE
136            renderer.line(self._to_screen(Vec2(t, -half)), self._to_screen(Vec2(t, half)), stroke=grid)
137            renderer.line(self._to_screen(Vec2(-half, t)), self._to_screen(Vec2(half, t)), stroke=grid)
138        for pin in self.pins:
139            renderer.circle(self._to_screen(pin), 10.0, fill=(1.0, 0.45, 0.3, 1.0))
140        scale = max(1.0, min(size.x, size.y) / 300)
141        renderer.text(
142            f"zoom {self.zoom:.2f}   turn {math.degrees(self.angle):.0f} deg   pins {len(self.pins)}",
143            (10 * scale, 10 * scale),
144            fill=(0.9, 0.95, 1.0, 1.0),
145            scale=scale,
146        )
147
148
149def _selftest() -> bool:
150    """Headless: pinch, twist, pan, long-press and two-finger-tap through the real touch path."""
151    import time
152
153    from simvx.core.testing import InputSimulator
154
155    app = App(title="Gestures", width=WINDOW_W, height=WINDOW_H, visible=False)
156    scene = GestureMap(name="GestureMap")
157    sim = InputSimulator(scene)
158    seen: dict[str, float] = {}
159    cx, cy = WINDOW_W / 2, WINDOW_H / 2
160
161    def on_frame(idx: int, _t: float) -> bool:
162        if idx == 0:
163            # Headless frames are short, so the hold asked for is too.
164            scene.tree.long_press_time = 0.05
165        if idx == 2:
166            sim.touch_down(0, (cx - 50, cy))
167            sim.touch_down(1, (cx + 50, cy))
168        elif idx == 3:
169            sim.touch_move(1, (cx + 150, cy))  # spread: zoom in
170        elif idx == 5:
171            seen["zoom"] = scene.zoom
172            sim.touch_move(1, (cx - 50, cy + 200))  # swing round the first finger: turn
173        elif idx == 7:
174            seen["angle"] = scene.angle
175            sim.touch_move(0, (cx - 20, cy + 30))
176            sim.touch_move(1, (cx - 20, cy + 230))  # both together: pan
177        elif idx == 9:
178            seen["pan"] = scene.offset.length()
179            sim.touch_up(0, (cx - 20, cy + 30))
180            sim.touch_up(1, (cx - 20, cy + 230))
181        elif idx == 12:
182            sim.touch_down(0, (cx, cy))  # held still: a long press, the right click here
183        elif 12 < idx < 400 and "pins" not in seen:
184            if scene.pins or idx == 399:
185                sim.touch_up(0, (cx, cy))
186                seen["pins"] = len(scene.pins)
187                seen["tap_at"] = idx + 2
188            else:
189                time.sleep(0.005)
190        elif idx == seen.get("tap_at"):
191            sim.touch_down(0, (cx, cy))
192            sim.touch_down(1, (cx + 40, cy))
193            sim.touch_up(0, (cx, cy))
194            sim.touch_up(1, (cx + 40, cy))
195        elif "tap_at" in seen and idx == seen["tap_at"] + 2:
196            seen["reset"] = scene.zoom
197            return False
198        return True
199
200    app.run_headless(scene, frames=420, on_frame=on_frame)
201    checks = {
202        "a pinch zoomed in": seen.get("zoom", 1.0) > 1.0,
203        "a twist turned the map": abs(seen.get("angle", 0.0)) > 0.1,
204        "two fingers panned it": seen.get("pan", 0.0) > 10.0,
205        "a long press dropped a pin": seen.get("pins") == 1,
206        "a two-finger tap reset the view": seen.get("reset") == 1.0,
207    }
208    for name, ok in checks.items():
209        print(("PASS " if ok else "FAIL ") + name)
210    return all(checks.values())
211
212
213if __name__ == "__main__":
214    import sys
215
216    if "--test" in sys.argv:
217        sys.exit(0 if _selftest() else 1)
218    App(title="Gestures as Controls", width=WINDOW_W, height=WINDOW_H).run(GestureMap())