Skip to main content

agg_gui/
touch_state.rs

1//! Multi-touch gesture recogniser.
2//!
3//! The platform shells (web JS, native winit) forward raw touch events
4//! to [`App::on_touch_start/move/end/cancel`].  [`TouchState`] maintains
5//! the set of active touches and, once two or more fingers are down,
6//! aggregates them each frame into a [`MultiTouchInfo`] describing zoom,
7//! rotation, pan, and average pressure relative to the previous frame.
8//!
9//! Widgets that want to react to gestures read the current frame's
10//! aggregate via [`current_multi_touch`], a thread-local written by
11//! [`App::publish_multi_touch`] at the start of each paint.  Single-
12//! finger touches continue to flow through the regular mouse-emulation
13//! path, so existing widgets keep working with no changes.
14//!
15//! The API shape deliberately mirrors egui's (`zoom_delta`,
16//! `rotation_delta`, `translation_delta`, `num_touches`, `center_pos`)
17//! so ports from egui code read cleanly.
18
19use std::cell::RefCell;
20use std::collections::BTreeMap;
21
22use crate::geometry::Point;
23
24// ---------------------------------------------------------------------------
25// Identifier newtypes
26// ---------------------------------------------------------------------------
27
28/// Stable per-device identifier.  Different physical input surfaces
29/// (e.g. a laptop's built-in touchscreen and a connected tablet) hash
30/// to different values.  The web shell always uses `0` (the browser
31/// doesn't expose multiple touch devices to pages); winit passes
32/// through its device id.
33#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
34pub struct TouchDeviceId(pub u64);
35
36/// Per-finger identifier, stable from Start through End/Cancel.  Re-
37/// used after lift — browsers and winit both guarantee identifiers
38/// are unique only for the lifetime of the touch.
39#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
40pub struct TouchId(pub u64);
41
42/// Which phase of the gesture this touch event represents.
43#[derive(Copy, Clone, Debug, PartialEq, Eq)]
44pub enum TouchPhase {
45    /// Finger first made contact.
46    Start,
47    /// Finger moved while in contact.
48    Move,
49    /// Finger lifted normally.
50    End,
51    /// Touch was cancelled by the platform (phone call, gesture
52    /// hand-off to browser, etc.).
53    Cancel,
54}
55
56// ---------------------------------------------------------------------------
57// MultiTouchInfo — the per-frame aggregate
58// ---------------------------------------------------------------------------
59
60/// Gesture aggregate for the current frame, produced when two or more
61/// fingers are on the same device.  All deltas are relative to the
62/// previous frame's positions — the widget just accumulates them into
63/// its own angle / scale / translation state (see `LionView` for the
64/// canonical consumer).
65#[derive(Copy, Clone, Debug)]
66pub struct MultiTouchInfo {
67    /// Device that owns these touches.  Useful only when the host
68    /// actually distinguishes multiple touchscreens; most apps ignore.
69    pub device_id: TouchDeviceId,
70    /// Number of fingers currently down (always ≥ 2 — a single-finger
71    /// frame produces `None` instead of a [`MultiTouchInfo`]).
72    pub num_touches: usize,
73    /// Multiplicative zoom factor since the last frame.  `1.0` means
74    /// "no pinch this frame"; `1.1` means the fingers spread by 10 %.
75    pub zoom_delta: f32,
76    /// Rotation in radians since the last frame.  Positive = CCW in
77    /// widget-local (Y-up) space, i.e. visually counter-clockwise on
78    /// screen.
79    pub rotation_delta: f32,
80    /// Translation of the centroid since the last frame, in widget-
81    /// local pixels.  Widgets that want the gesture to orbit the pinch
82    /// centre should combine this with `zoom_delta` / `rotation_delta`.
83    pub translation_delta: Point,
84    /// Average `force` across active touches, or `0.0` when the
85    /// platform doesn't report pressure.
86    pub force: f32,
87    /// Centroid of the active touches in app-local coordinates this
88    /// frame.  Widgets that want to hit-test "is the gesture over me?"
89    /// compare this against their own absolute bounds.
90    pub center_pos: Point,
91}
92
93// ---------------------------------------------------------------------------
94// TouchState — per-frame gesture recogniser
95// ---------------------------------------------------------------------------
96
97/// One finger's tracked position, updated every Move event.
98#[derive(Copy, Clone, Debug)]
99struct ActiveTouch {
100    /// Latest position reported by the platform.
101    pos: Point,
102    /// Position at the last `update_gesture` call — used as the basis
103    /// for the next delta.
104    prev_pos: Point,
105    /// Latest force (0.0 when unsupported).
106    force: f32,
107}
108
109/// Tracks every active touch across every known device.  Lives on
110/// `App`; widgets never see this directly.
111#[derive(Default)]
112pub struct TouchState {
113    active: BTreeMap<(TouchDeviceId, TouchId), ActiveTouch>,
114    /// Result of the most recent `update_gesture` call — `None` while
115    /// fewer than two fingers are down on any one device.  Published
116    /// to the thread-local so widgets can read it during paint.
117    last: Option<MultiTouchInfo>,
118    /// Set by Start / End / Cancel so `update_gesture` can reseed
119    /// `prev_pos` on the frame after a finger count change — without
120    /// this, newly-arrived fingers contribute a spurious delta equal
121    /// to their full spread on their first move.
122    topology_changed: bool,
123}
124
125impl TouchState {
126    pub fn new() -> Self { Self::default() }
127
128    pub fn on_start(
129        &mut self,
130        device: TouchDeviceId,
131        id: TouchId,
132        pos: Point,
133        force: Option<f32>,
134    ) {
135        self.active.insert(
136            (device, id),
137            ActiveTouch { pos, prev_pos: pos, force: force.unwrap_or(0.0) },
138        );
139        self.topology_changed = true;
140    }
141
142    pub fn on_move(
143        &mut self,
144        device: TouchDeviceId,
145        id: TouchId,
146        pos: Point,
147        force: Option<f32>,
148    ) {
149        if let Some(t) = self.active.get_mut(&(device, id)) {
150            t.pos = pos;
151            if let Some(f) = force { t.force = f; }
152        }
153    }
154
155    pub fn on_end_or_cancel(&mut self, device: TouchDeviceId, id: TouchId) {
156        if self.active.remove(&(device, id)).is_some() {
157            self.topology_changed = true;
158        }
159        if self.active.len() < 2 {
160            self.last = None;
161        }
162    }
163
164    /// Recompute the per-frame aggregate.  Called by `App` right before
165    /// the multi-touch value is published, so every `paint` / `on_event`
166    /// in the same frame sees consistent deltas.
167    pub fn update_gesture(&mut self) {
168        // Only the most-populated device contributes — the common case
169        // is a single touchscreen, and cross-device gestures aren't a
170        // useful abstraction.
171        let device = self.active.keys().next().map(|(d, _)| *d);
172        let Some(device) = device else { self.last = None; return; };
173        let touches: Vec<ActiveTouch> = self.active.iter()
174            .filter(|((d, _), _)| *d == device)
175            .map(|(_, t)| *t)
176            .collect();
177        if touches.len() < 2 {
178            self.last = None;
179            return;
180        }
181
182        // Centroid (previous vs current) drives the translation delta.
183        let n = touches.len() as f64;
184        let (mut cx, mut cy)   = (0.0, 0.0);
185        let (mut pcx, mut pcy) = (0.0, 0.0);
186        for t in &touches {
187            cx  += t.pos.x;      cy  += t.pos.y;
188            pcx += t.prev_pos.x; pcy += t.prev_pos.y;
189        }
190        cx /= n; cy /= n; pcx /= n; pcy /= n;
191
192        // Average pinch + rotation across pairs.  Using every
193        // (touch, centroid) ray means the signal scales sensibly with
194        // finger count; egui does the same.
195        let mut zoom_sum     = 0.0_f32;
196        let mut rotation_sum = 0.0_f32;
197        let mut force_sum    = 0.0_f32;
198        let mut zoom_count   = 0;
199        for t in &touches {
200            force_sum += t.force;
201            let dx  = (t.pos.x - cx) as f32;
202            let dy  = (t.pos.y - cy) as f32;
203            let pdx = (t.prev_pos.x - pcx) as f32;
204            let pdy = (t.prev_pos.y - pcy) as f32;
205            let r  = (dx  * dx  + dy  * dy ).sqrt();
206            let pr = (pdx * pdx + pdy * pdy).sqrt();
207            if pr > 1.0 && r > 1.0 {
208                zoom_sum     += r / pr;
209                rotation_sum += dy.atan2(dx) - pdy.atan2(pdx);
210                zoom_count   += 1;
211            }
212        }
213        // Skip producing a frame-delta when topology just changed —
214        // the jump from "no prev_pos" to "current pos" would otherwise
215        // read as a huge one-frame zoom.  We still emit an info entry
216        // so widgets can react to finger count; just with zeroed
217        // deltas.
218        let (zoom_delta, rotation_delta) = if self.topology_changed || zoom_count == 0 {
219            (1.0, 0.0)
220        } else {
221            // Normalise rotation to `[-pi, pi]` so wrap-around at the
222            // ±pi seam doesn't flip sign of the delta.
223            let mut rot = rotation_sum / zoom_count as f32;
224            use std::f32::consts::PI;
225            while rot >  PI { rot -= 2.0 * PI; }
226            while rot < -PI { rot += 2.0 * PI; }
227            (zoom_sum / zoom_count as f32, rot)
228        };
229
230        let translation_delta = if self.topology_changed {
231            Point::new(0.0, 0.0)
232        } else {
233            Point::new(cx - pcx, cy - pcy)
234        };
235
236        self.last = Some(MultiTouchInfo {
237            device_id:         device,
238            num_touches:       touches.len(),
239            zoom_delta,
240            rotation_delta,
241            translation_delta,
242            force:             force_sum / n as f32,
243            center_pos:        Point::new(cx, cy),
244        });
245
246        // Latch current positions as the new baseline for the next
247        // frame, then clear the topology flag.
248        for t in self.active.values_mut() {
249            t.prev_pos = t.pos;
250        }
251        self.topology_changed = false;
252    }
253
254    pub fn current(&self) -> Option<MultiTouchInfo> { self.last }
255
256    /// Total number of fingers currently down (across all devices).
257    /// Useful as a lightweight "are we in a gesture?" probe when a
258    /// widget doesn't care about the per-delta aggregate.
259    pub fn active_count(&self) -> usize { self.active.len() }
260}
261
262// ---------------------------------------------------------------------------
263// Thread-local publish / read
264// ---------------------------------------------------------------------------
265
266thread_local! {
267    static CURRENT: RefCell<Option<MultiTouchInfo>> = RefCell::new(None);
268}
269
270/// Publish this frame's multi-touch aggregate.  Called by
271/// `App::paint` right before painting begins.
272pub fn set_current(info: Option<MultiTouchInfo>) {
273    CURRENT.with(|c| *c.borrow_mut() = info);
274}
275
276/// Fetch the current frame's multi-touch aggregate.  Returns `None`
277/// when fewer than two fingers are down on any device, so a widget
278/// writes: `if let Some(mt) = current_multi_touch() { … }`.
279pub fn current_multi_touch() -> Option<MultiTouchInfo> {
280    CURRENT.with(|c| *c.borrow())
281}