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}