Gestures as controls¶
pinch, twist and pan a map, long-press to drop a pin
▶ Run in browserTags: 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.ROTATEandMouse.WHEELas axes, read withInput.value: each is the frame’s motion and rests at zero.A scaled binding:
Mouse.WHEEL * 0.1puts a wheel notch in the pinch’s unit, so onezoomaction serves both.Touch.PANas a stick, bound once into aVec2action.Touch.TWO_FINGER_TAPas a button.Input.touch_secondary: a long press joinsPointer.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())