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}