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 { dx, dy, x: None, y: None }
160    }
161
162    /// Create a [`Pan`](Self::Pan) event at a specific cursor location.
163    #[inline]
164    pub fn pan_at(dx: f64, dy: f64, x: f64, y: f64) -> Self {
165        Self::Pan { dx, dy, x: Some(x), y: Some(y) }
166    }
167
168    /// Create a [`Zoom`](Self::Zoom) event that zooms **in**.
169    ///
170    /// `factor` should be `> 1.0`.  Values ? 0 will be ignored by the
171    /// controller.
172    #[inline]
173    pub fn zoom_in(factor: f64) -> Self {
174        Self::Zoom {
175            factor,
176            x: None,
177            y: None,
178        }
179    }
180
181    /// Create a [`Zoom`](Self::Zoom) event around a specific cursor location.
182    #[inline]
183    pub fn zoom_at(factor: f64, x: f64, y: f64) -> Self {
184        Self::Zoom {
185            factor,
186            x: Some(x),
187            y: Some(y),
188        }
189    }
190
191    /// Create a [`Zoom`](Self::Zoom) event that zooms **out**.
192    ///
193    /// `factor` should be `> 1.0`; the reciprocal is stored so
194    /// the controller sees a value in `(0, 1)`.
195    #[inline]
196    pub fn zoom_out(factor: f64) -> Self {
197        Self::Zoom {
198            factor: if factor > 0.0 { 1.0 / factor } else { 0.0 },
199            x: None,
200            y: None,
201        }
202    }
203
204    /// Create a [`Rotate`](Self::Rotate) event.
205    #[inline]
206    pub fn rotate(delta_yaw: f64, delta_pitch: f64) -> Self {
207        Self::Rotate {
208            delta_yaw,
209            delta_pitch,
210        }
211    }
212
213    /// Create a [`Resize`](Self::Resize) event.
214    #[inline]
215    pub fn resize(width: u32, height: u32) -> Self {
216        Self::Resize { width, height }
217    }
218
219    /// Create a [`Touch`](Self::Touch) event.
220    #[inline]
221    pub fn touch(id: u64, phase: TouchPhase, x: f64, y: f64) -> Self {
222        Self::Touch(TouchContact { id, phase, x, y })
223    }
224}
225
226// ---------------------------------------------------------------------------
227// Classification helpers
228// ---------------------------------------------------------------------------
229
230impl InputEvent {
231    /// Returns `true` if this is a [`Pan`](Self::Pan) event.
232    #[inline]
233    pub fn is_pan(&self) -> bool {
234        matches!(self, Self::Pan { .. })
235    }
236
237    /// Returns `true` if this is a [`Zoom`](Self::Zoom) event.
238    #[inline]
239    pub fn is_zoom(&self) -> bool {
240        matches!(self, Self::Zoom { .. })
241    }
242
243    /// Returns `true` if this is a [`Rotate`](Self::Rotate) event.
244    #[inline]
245    pub fn is_rotate(&self) -> bool {
246        matches!(self, Self::Rotate { .. })
247    }
248
249    /// Returns `true` if this is a [`Resize`](Self::Resize) event.
250    #[inline]
251    pub fn is_resize(&self) -> bool {
252        matches!(self, Self::Resize { .. })
253    }
254
255    /// Returns `true` if this is a [`Touch`](Self::Touch) event.
256    #[inline]
257    pub fn is_touch(&self) -> bool {
258        matches!(self, Self::Touch(_))
259    }
260}
261
262// ---------------------------------------------------------------------------
263// Display
264// ---------------------------------------------------------------------------
265
266impl fmt::Display for InputEvent {
267    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
268        match self {
269            Self::Pan { dx, dy, x, y } => {
270                if let (Some(px), Some(py)) = (x, y) {
271                    write!(f, "Pan(dx={dx:.1}, dy={dy:.1}, at={px:.1},{py:.1})")
272                } else {
273                    write!(f, "Pan(dx={dx:.1}, dy={dy:.1})")
274                }
275            }
276            Self::Zoom { factor, x, y } => {
277                if let (Some(px), Some(py)) = (x, y) {
278                    write!(f, "Zoom(factor={factor:.3}, at={px:.1},{py:.1})")
279                } else {
280                    write!(f, "Zoom(factor={factor:.3})")
281                }
282            }
283            Self::Rotate {
284                delta_yaw,
285                delta_pitch,
286            } => write!(
287                f,
288                "Rotate(yaw={delta_yaw:.4}, pitch={delta_pitch:.4})"
289            ),
290            Self::Resize { width, height } => {
291                write!(f, "Resize({width}x{height})")
292            }
293            Self::Touch(c) => {
294                write!(f, "Touch(id={}, {:?}, {:.1},{:.1})", c.id, c.phase, c.x, c.y)
295            }
296        }
297    }
298}
299
300// ---------------------------------------------------------------------------
301// Tests
302// ---------------------------------------------------------------------------
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    // -- Construction -----------------------------------------------------
309
310    #[test]
311    fn pan_constructor() {
312        let e = InputEvent::pan(10.0, -5.0);
313        assert_eq!(
314            e,
315            InputEvent::Pan {
316                dx: 10.0,
317                dy: -5.0,
318                x: None,
319                y: None,
320            }
321        );
322    }
323
324    #[test]
325    fn zoom_in_constructor() {
326        let e = InputEvent::zoom_in(2.0);
327        assert_eq!(
328            e,
329            InputEvent::Zoom {
330                factor: 2.0,
331                x: None,
332                y: None,
333            }
334        );
335    }
336
337    #[test]
338    fn zoom_at_constructor() {
339        let e = InputEvent::zoom_at(2.0, 10.0, 20.0);
340        assert_eq!(
341            e,
342            InputEvent::Zoom {
343                factor: 2.0,
344                x: Some(10.0),
345                y: Some(20.0),
346            }
347        );
348    }
349
350    #[test]
351    fn zoom_out_constructor() {
352        let e = InputEvent::zoom_out(2.0);
353        assert_eq!(
354            e,
355            InputEvent::Zoom {
356                factor: 0.5,
357                x: None,
358                y: None,
359            }
360        );
361    }
362
363    #[test]
364    fn zoom_out_zero_factor() {
365        let e = InputEvent::zoom_out(0.0);
366        assert_eq!(
367            e,
368            InputEvent::Zoom {
369                factor: 0.0,
370                x: None,
371                y: None,
372            }
373        );
374    }
375
376    #[test]
377    fn rotate_constructor() {
378        let e = InputEvent::rotate(0.1, 0.2);
379        assert_eq!(
380            e,
381            InputEvent::Rotate {
382                delta_yaw: 0.1,
383                delta_pitch: 0.2
384            }
385        );
386    }
387
388    #[test]
389    fn resize_constructor() {
390        let e = InputEvent::resize(1920, 1080);
391        assert_eq!(
392            e,
393            InputEvent::Resize {
394                width: 1920,
395                height: 1080
396            }
397        );
398    }
399
400    // -- Classification ---------------------------------------------------
401
402    #[test]
403    fn is_pan() {
404        assert!(InputEvent::pan(1.0, 2.0).is_pan());
405        assert!(!InputEvent::zoom_in(1.0).is_pan());
406    }
407
408    #[test]
409    fn is_zoom() {
410        assert!(InputEvent::zoom_in(1.0).is_zoom());
411        assert!(!InputEvent::pan(0.0, 0.0).is_zoom());
412    }
413
414    #[test]
415    fn is_rotate() {
416        assert!(InputEvent::rotate(0.0, 0.0).is_rotate());
417        assert!(!InputEvent::resize(0, 0).is_rotate());
418    }
419
420    #[test]
421    fn is_resize() {
422        assert!(InputEvent::resize(800, 600).is_resize());
423        assert!(!InputEvent::rotate(0.0, 0.0).is_resize());
424    }
425
426    // -- Display ----------------------------------------------------------
427
428    #[test]
429    fn display_pan() {
430        let s = format!("{}", InputEvent::pan(10.0, -5.0));
431        assert!(s.contains("Pan"));
432        assert!(s.contains("10.0"));
433    }
434
435    #[test]
436    fn display_zoom() {
437        let s = format!("{}", InputEvent::zoom_in(1.5));
438        assert!(s.contains("Zoom"));
439        assert!(s.contains("1.5"));
440    }
441
442    #[test]
443    fn display_rotate() {
444        let s = format!("{}", InputEvent::rotate(0.1, 0.2));
445        assert!(s.contains("Rotate"));
446    }
447
448    #[test]
449    fn display_resize() {
450        let s = format!("{}", InputEvent::resize(1920, 1080));
451        assert!(s.contains("1920"));
452        assert!(s.contains("1080"));
453    }
454
455    // -- Equality / Copy --------------------------------------------------
456
457    #[test]
458    fn copy_semantics() {
459        let a = InputEvent::pan(1.0, 2.0);
460        let b = a; // Copy
461        assert_eq!(a, b);
462    }
463
464    #[test]
465    fn clone_eq() {
466        let a = InputEvent::zoom_in(3.0);
467        #[allow(clippy::clone_on_copy)]
468        let b = a.clone();
469        assert_eq!(a, b);
470    }
471
472    #[test]
473    fn different_variants_not_equal() {
474        assert_ne!(InputEvent::pan(0.0, 0.0), InputEvent::zoom_in(1.0));
475    }
476}