Skip to main content

rustial_engine/
input.rs

1//! Input event protocol for the map engine.
2//!
3//! [`InputEvent`] is the **sole** channel through which external input
4//! reaches the engine camera.  It is consumed by:
5//!
6//! - [`CameraController::handle_event`](crate::CameraController) --
7//!   dispatches to `pan`, `zoom`, `rotate`, or viewport resize.
8//! - [`MapState::handle_input`](crate::MapState) -- convenience
9//!   forwarding wrapper used by host applications.
10//! - The Bevy renderer (`map_input::handle_default_input`) -- translates
11//!   mouse / keyboard Bevy events into `InputEvent` values.
12//! - Pure WGPU host applications -- translate winit events manually.
13//!
14//! # Design
15//!
16//! - **Framework-agnostic**: no dependency on winit, Bevy, or any
17//!   windowing crate.  The host is responsible for producing events.
18//! - **Value semantics**: `Copy + Clone + PartialEq + Debug` -- cheap
19//!   to pass, compare, and log.
20//! - **Units are documented per variant**: pixels for spatial deltas,
21//!   radians for rotation, multiplicative factor for zoom.
22//! - Convenience constructors (`pan`, `zoom_in`, `zoom_out`, `rotate`,
23//!   `resize`) are provided so callers do not need to write the struct
24//!   literal syntax.
25
26use std::fmt;
27
28// ---------------------------------------------------------------------------
29// Touch types
30// ---------------------------------------------------------------------------
31
32/// Phase of a touch contact's lifecycle.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34pub enum TouchPhase {
35    /// A new finger has made contact with the screen.
36    Started,
37    /// An existing touch has moved.
38    Moved,
39    /// The finger has lifted from the screen.
40    Ended,
41    /// The OS cancelled the touch (e.g. palm rejection).
42    Cancelled,
43}
44
45/// A single touch contact point.
46///
47/// The `id` field uniquely identifies a finger across its lifecycle
48/// (started → moved → ended).  Coordinates are in **logical pixels**.
49#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct TouchContact {
51    /// Unique identifier for this finger (stable across phases).
52    pub id: u64,
53    /// Phase of the touch event.
54    pub phase: TouchPhase,
55    /// X position in logical pixels.
56    pub x: f64,
57    /// Y position in logical pixels.
58    pub y: f64,
59}
60
61// ---------------------------------------------------------------------------
62// InputEvent
63// ---------------------------------------------------------------------------
64
65/// An input event that can be dispatched to the engine.
66///
67/// All spatial values are in **logical pixels** unless otherwise noted.
68/// Rotation values are in **radians**.
69///
70/// # Examples
71///
72/// ```
73/// use rustial_engine::InputEvent;
74///
75/// // Drag the map 10 px right and 5 px down.
76/// let pan = InputEvent::pan(10.0, 5.0);
77///
78/// // Zoom in by 10 %.
79/// let zoom = InputEvent::zoom_in(1.1);
80///
81/// // Tilt the camera 5 degrees (? 0.087 rad).
82/// let rotate = InputEvent::rotate(0.0, 0.087);
83///
84/// // Viewport resized.
85/// let resize = InputEvent::resize(1920, 1080);
86/// ```
87#[derive(Debug, Clone, Copy, PartialEq)]
88pub enum InputEvent {
89    /// Pan the camera by a screen-space delta.
90    ///
91    /// Positive `dx` moves the viewport to the **right** (map moves left).
92    /// Positive `dy` moves the viewport **down** (map moves up).
93    Pan {
94        /// Horizontal pixel delta (positive = right).
95        dx: f64,
96        /// Vertical pixel delta (positive = down).
97        dy: f64,
98        /// Cursor's X position in logical pixels (where the drag started or currently is).
99        x: Option<f64>,
100        /// Cursor's Y position in logical pixels.
101        y: Option<f64>,
102    },
103
104    /// Zoom by a multiplicative factor.
105    ///
106    /// - `factor > 1.0` zooms **in** (closer to the ground).
107    /// - `0 < factor < 1.0` zooms **out**.
108    /// - `factor <= 0`, `NaN`, or `+/-Inf` are silently ignored by the
109    ///   [`CameraController`](crate::CameraController).
110    Zoom {
111        /// Multiplicative zoom factor.
112        factor: f64,
113        /// Cursor X position in logical pixels used as the zoom anchor.
114        x: Option<f64>,
115        /// Cursor Y position in logical pixels used as the zoom anchor.
116        y: Option<f64>,
117    },
118
119    /// Rotate the camera by delta yaw and delta pitch.
120    ///
121    /// - `delta_yaw` rotates the bearing (positive = clockwise when
122    ///   viewed from above).
123    /// - `delta_pitch` tilts the camera (positive = toward horizon).
124    Rotate {
125        /// Change in yaw (bearing) in radians.
126        delta_yaw: f64,
127        /// Change in pitch (tilt) in radians.
128        delta_pitch: f64,
129    },
130
131    /// Notify the engine of a viewport resize.
132    ///
133    /// The engine uses logical pixel dimensions (not physical) so that
134    /// zoom-level calculations match the standard slippy-map convention.
135    Resize {
136        /// New viewport width in logical pixels.
137        width: u32,
138        /// New viewport height in logical pixels.
139        height: u32,
140    },
141
142    /// A raw touch contact event.
143    ///
144    /// The host application emits one `Touch` per finger per phase
145    /// change.  The engine's [`GestureRecognizer`](crate::gesture::GestureRecognizer)
146    /// accumulates these into high-level `Pan` / `Zoom` / `Rotate`
147    /// events.
148    Touch(TouchContact),
149}
150
151// ---------------------------------------------------------------------------
152// Convenience constructors
153// ---------------------------------------------------------------------------
154
155impl InputEvent {
156    /// Create a [`Pan`](Self::Pan) event.
157    #[inline]
158    pub fn pan(dx: f64, dy: f64) -> Self {
159        Self::Pan {
160            dx,
161            dy,
162            x: None,
163            y: None,
164        }
165    }
166
167    /// Create a [`Pan`](Self::Pan) event at a specific cursor location.
168    #[inline]
169    pub fn pan_at(dx: f64, dy: f64, x: f64, y: f64) -> Self {
170        Self::Pan {
171            dx,
172            dy,
173            x: Some(x),
174            y: Some(y),
175        }
176    }
177
178    /// Create a [`Zoom`](Self::Zoom) event that zooms **in**.
179    ///
180    /// `factor` should be `> 1.0`.  Values ? 0 will be ignored by the
181    /// controller.
182    #[inline]
183    pub fn zoom_in(factor: f64) -> Self {
184        Self::Zoom {
185            factor,
186            x: None,
187            y: None,
188        }
189    }
190
191    /// Create a [`Zoom`](Self::Zoom) event around a specific cursor location.
192    #[inline]
193    pub fn zoom_at(factor: f64, x: f64, y: f64) -> Self {
194        Self::Zoom {
195            factor,
196            x: Some(x),
197            y: Some(y),
198        }
199    }
200
201    /// Create a [`Zoom`](Self::Zoom) event that zooms **out**.
202    ///
203    /// `factor` should be `> 1.0`; the reciprocal is stored so
204    /// the controller sees a value in `(0, 1)`.
205    #[inline]
206    pub fn zoom_out(factor: f64) -> Self {
207        Self::Zoom {
208            factor: if factor > 0.0 { 1.0 / factor } else { 0.0 },
209            x: None,
210            y: None,
211        }
212    }
213
214    /// Create a [`Rotate`](Self::Rotate) event.
215    #[inline]
216    pub fn rotate(delta_yaw: f64, delta_pitch: f64) -> Self {
217        Self::Rotate {
218            delta_yaw,
219            delta_pitch,
220        }
221    }
222
223    /// Create a [`Resize`](Self::Resize) event.
224    #[inline]
225    pub fn resize(width: u32, height: u32) -> Self {
226        Self::Resize { width, height }
227    }
228
229    /// Create a [`Touch`](Self::Touch) event.
230    #[inline]
231    pub fn touch(id: u64, phase: TouchPhase, x: f64, y: f64) -> Self {
232        Self::Touch(TouchContact { id, phase, x, y })
233    }
234}
235
236// ---------------------------------------------------------------------------
237// Classification helpers
238// ---------------------------------------------------------------------------
239
240impl InputEvent {
241    /// Returns `true` if this is a [`Pan`](Self::Pan) event.
242    #[inline]
243    pub fn is_pan(&self) -> bool {
244        matches!(self, Self::Pan { .. })
245    }
246
247    /// Returns `true` if this is a [`Zoom`](Self::Zoom) event.
248    #[inline]
249    pub fn is_zoom(&self) -> bool {
250        matches!(self, Self::Zoom { .. })
251    }
252
253    /// Returns `true` if this is a [`Rotate`](Self::Rotate) event.
254    #[inline]
255    pub fn is_rotate(&self) -> bool {
256        matches!(self, Self::Rotate { .. })
257    }
258
259    /// Returns `true` if this is a [`Resize`](Self::Resize) event.
260    #[inline]
261    pub fn is_resize(&self) -> bool {
262        matches!(self, Self::Resize { .. })
263    }
264
265    /// Returns `true` if this is a [`Touch`](Self::Touch) event.
266    #[inline]
267    pub fn is_touch(&self) -> bool {
268        matches!(self, Self::Touch(_))
269    }
270}
271
272// ---------------------------------------------------------------------------
273// Display
274// ---------------------------------------------------------------------------
275
276impl fmt::Display for InputEvent {
277    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
278        match self {
279            Self::Pan { dx, dy, x, y } => {
280                if let (Some(px), Some(py)) = (x, y) {
281                    write!(f, "Pan(dx={dx:.1}, dy={dy:.1}, at={px:.1},{py:.1})")
282                } else {
283                    write!(f, "Pan(dx={dx:.1}, dy={dy:.1})")
284                }
285            }
286            Self::Zoom { factor, x, y } => {
287                if let (Some(px), Some(py)) = (x, y) {
288                    write!(f, "Zoom(factor={factor:.3}, at={px:.1},{py:.1})")
289                } else {
290                    write!(f, "Zoom(factor={factor:.3})")
291                }
292            }
293            Self::Rotate {
294                delta_yaw,
295                delta_pitch,
296            } => write!(f, "Rotate(yaw={delta_yaw:.4}, pitch={delta_pitch:.4})"),
297            Self::Resize { width, height } => {
298                write!(f, "Resize({width}x{height})")
299            }
300            Self::Touch(c) => {
301                write!(
302                    f,
303                    "Touch(id={}, {:?}, {:.1},{:.1})",
304                    c.id, c.phase, c.x, c.y
305                )
306            }
307        }
308    }
309}
310
311// ---------------------------------------------------------------------------
312// Tests
313// ---------------------------------------------------------------------------
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    // -- Construction -----------------------------------------------------
320
321    #[test]
322    fn pan_constructor() {
323        let e = InputEvent::pan(10.0, -5.0);
324        assert_eq!(
325            e,
326            InputEvent::Pan {
327                dx: 10.0,
328                dy: -5.0,
329                x: None,
330                y: None,
331            }
332        );
333    }
334
335    #[test]
336    fn zoom_in_constructor() {
337        let e = InputEvent::zoom_in(2.0);
338        assert_eq!(
339            e,
340            InputEvent::Zoom {
341                factor: 2.0,
342                x: None,
343                y: None,
344            }
345        );
346    }
347
348    #[test]
349    fn zoom_at_constructor() {
350        let e = InputEvent::zoom_at(2.0, 10.0, 20.0);
351        assert_eq!(
352            e,
353            InputEvent::Zoom {
354                factor: 2.0,
355                x: Some(10.0),
356                y: Some(20.0),
357            }
358        );
359    }
360
361    #[test]
362    fn zoom_out_constructor() {
363        let e = InputEvent::zoom_out(2.0);
364        assert_eq!(
365            e,
366            InputEvent::Zoom {
367                factor: 0.5,
368                x: None,
369                y: None,
370            }
371        );
372    }
373
374    #[test]
375    fn zoom_out_zero_factor() {
376        let e = InputEvent::zoom_out(0.0);
377        assert_eq!(
378            e,
379            InputEvent::Zoom {
380                factor: 0.0,
381                x: None,
382                y: None,
383            }
384        );
385    }
386
387    #[test]
388    fn rotate_constructor() {
389        let e = InputEvent::rotate(0.1, 0.2);
390        assert_eq!(
391            e,
392            InputEvent::Rotate {
393                delta_yaw: 0.1,
394                delta_pitch: 0.2
395            }
396        );
397    }
398
399    #[test]
400    fn resize_constructor() {
401        let e = InputEvent::resize(1920, 1080);
402        assert_eq!(
403            e,
404            InputEvent::Resize {
405                width: 1920,
406                height: 1080
407            }
408        );
409    }
410
411    // -- Classification ---------------------------------------------------
412
413    #[test]
414    fn is_pan() {
415        assert!(InputEvent::pan(1.0, 2.0).is_pan());
416        assert!(!InputEvent::zoom_in(1.0).is_pan());
417    }
418
419    #[test]
420    fn is_zoom() {
421        assert!(InputEvent::zoom_in(1.0).is_zoom());
422        assert!(!InputEvent::pan(0.0, 0.0).is_zoom());
423    }
424
425    #[test]
426    fn is_rotate() {
427        assert!(InputEvent::rotate(0.0, 0.0).is_rotate());
428        assert!(!InputEvent::resize(0, 0).is_rotate());
429    }
430
431    #[test]
432    fn is_resize() {
433        assert!(InputEvent::resize(800, 600).is_resize());
434        assert!(!InputEvent::rotate(0.0, 0.0).is_resize());
435    }
436
437    // -- Display ----------------------------------------------------------
438
439    #[test]
440    fn display_pan() {
441        let s = format!("{}", InputEvent::pan(10.0, -5.0));
442        assert!(s.contains("Pan"));
443        assert!(s.contains("10.0"));
444    }
445
446    #[test]
447    fn display_zoom() {
448        let s = format!("{}", InputEvent::zoom_in(1.5));
449        assert!(s.contains("Zoom"));
450        assert!(s.contains("1.5"));
451    }
452
453    #[test]
454    fn display_rotate() {
455        let s = format!("{}", InputEvent::rotate(0.1, 0.2));
456        assert!(s.contains("Rotate"));
457    }
458
459    #[test]
460    fn display_resize() {
461        let s = format!("{}", InputEvent::resize(1920, 1080));
462        assert!(s.contains("1920"));
463        assert!(s.contains("1080"));
464    }
465
466    // -- Equality / Copy --------------------------------------------------
467
468    #[test]
469    fn copy_semantics() {
470        let a = InputEvent::pan(1.0, 2.0);
471        let b = a; // Copy
472        assert_eq!(a, b);
473    }
474
475    #[test]
476    fn clone_eq() {
477        let a = InputEvent::zoom_in(3.0);
478        #[allow(clippy::clone_on_copy)]
479        let b = a.clone();
480        assert_eq!(a, b);
481    }
482
483    #[test]
484    fn different_variants_not_equal() {
485        assert_ne!(InputEvent::pan(0.0, 0.0), InputEvent::zoom_in(1.0));
486    }
487}