Skip to main content

viewport_lib/interaction/input/
viewport_input.rs

1//! Stateful viewport input accumulator and resolver.
2//!
3//! [`ViewportInput`] is the lower-level input resolver. Most consumers should
4//! use [`super::controller::OrbitCameraController`] which wraps it.
5
6use std::collections::HashSet;
7
8use super::action::Action;
9use super::action_frame::{ActionFrame, NavigationActions, ResolvedActionState};
10use super::binding::{KeyCode, Modifiers, MouseButton};
11use super::context::ViewportContext;
12use super::event::{ButtonState, ScrollUnits, ViewportEvent};
13use super::preset::{BindingPreset, viewport_all_bindings, viewport_primitives_bindings};
14use super::viewport_binding::{ViewportBinding, ViewportGesture};
15
16/// Pixels-per-line conversion for scroll delta normalisation.
17const PIXELS_PER_LINE: f32 = 28.0;
18
19/// Stateful viewport input accumulator.
20///
21/// Maintains pointer and button state across frames and resolves raw
22/// [`ViewportEvent`]s into semantic [`ActionFrame`] output.
23///
24/// # Frame lifecycle
25///
26/// ```text
27/// // --- AppState construction ---
28/// input.begin_frame(ctx);          // prime the accumulator
29///
30/// // --- Per winit window_event ---
31/// input.push_event(translated_event);
32///
33/// // --- RedrawRequested ---
34/// let actions = input.resolve();   // apply to camera / interactions
35/// input.begin_frame(ctx);          // reset for next frame's events
36/// ```
37pub struct ViewportInput {
38    bindings: Vec<ViewportBinding>,
39
40    // Per-frame accumulated deltas
41    drag_delta: glam::Vec2,
42    wheel_delta: glam::Vec2, // always in pixels
43    rotate_gesture: f32,     // accumulated two-finger rotation this frame, radians
44
45    // Per-frame key accumulators (reset by begin_frame)
46    keys_pressed: HashSet<KeyCode>,
47    /// Characters typed this frame (reset by begin_frame, drained into ActionFrame).
48    typed_chars: Vec<char>,
49
50    // Persistent state
51    pointer_pos: Option<glam::Vec2>,
52    /// Which buttons are currently held. Tracks three buttons.
53    button_held: [bool; 3], // [Left, Right, Middle]
54    /// Position at which each button was first pressed (to detect in-viewport press).
55    button_press_pos: [Option<glam::Vec2>; 3],
56    modifiers: Modifiers,
57    /// Keys currently held down (persistent across frames).
58    keys_held: HashSet<KeyCode>,
59
60    ctx: ViewportContext,
61}
62
63fn button_index(b: MouseButton) -> usize {
64    match b {
65        MouseButton::Left => 0,
66        MouseButton::Right => 1,
67        MouseButton::Middle => 2,
68    }
69}
70
71impl ViewportInput {
72    /// Create a new resolver with the given binding list.
73    pub fn new(bindings: Vec<ViewportBinding>) -> Self {
74        Self {
75            bindings,
76            drag_delta: glam::Vec2::ZERO,
77            wheel_delta: glam::Vec2::ZERO,
78            rotate_gesture: 0.0,
79            keys_pressed: HashSet::new(),
80            typed_chars: Vec::new(),
81            pointer_pos: None,
82            button_held: [false; 3],
83            button_press_pos: [None, None, None],
84            modifiers: Modifiers::NONE,
85            keys_held: HashSet::new(),
86            ctx: ViewportContext::default(),
87        }
88    }
89
90    /// Create a resolver for a named [`BindingPreset`].
91    pub fn from_preset(preset: BindingPreset) -> Self {
92        let bindings = match preset {
93            BindingPreset::ViewportPrimitives => viewport_primitives_bindings(),
94            BindingPreset::ViewportAll => viewport_all_bindings(),
95        };
96        Self::new(bindings)
97    }
98
99    /// Begin a new frame.
100    ///
101    /// Resets per-frame accumulators and records the current viewport context.
102    /// Call this at the END of each render so it's ready to accumulate the next
103    /// batch of events. Also call once during initialisation.
104    pub fn begin_frame(&mut self, ctx: ViewportContext) {
105        self.ctx = ctx;
106        self.drag_delta = glam::Vec2::ZERO;
107        self.wheel_delta = glam::Vec2::ZERO;
108        self.rotate_gesture = 0.0;
109        self.keys_pressed.clear();
110        self.typed_chars.clear();
111        // Note: persistent state (button_held, pointer_pos, modifiers, keys_held) is NOT reset.
112    }
113
114    /// Push a single viewport-scoped event into the accumulator.
115    pub fn push_event(&mut self, event: ViewportEvent) {
116        match event {
117            ViewportEvent::PointerMoved { position } => {
118                if let Some(prev) = self.pointer_pos {
119                    // Only accumulate drag delta when at least one button is held
120                    if self.button_held.iter().any(|&h| h) {
121                        self.drag_delta += position - prev;
122                    }
123                }
124                self.pointer_pos = Some(position);
125            }
126            ViewportEvent::MouseButton { button, state } => {
127                let idx = button_index(button);
128                match state {
129                    ButtonState::Pressed => {
130                        self.button_held[idx] = true;
131                        self.button_press_pos[idx] = self.pointer_pos;
132                    }
133                    ButtonState::Released => {
134                        self.button_held[idx] = false;
135                        self.button_press_pos[idx] = None;
136                    }
137                }
138            }
139            ViewportEvent::Wheel { delta, units } => {
140                let scale = match units {
141                    ScrollUnits::Lines => PIXELS_PER_LINE,
142                    ScrollUnits::Pixels => 1.0,
143                };
144                // Only accumulate if viewport is hovered
145                if self.ctx.hovered {
146                    self.wheel_delta += delta * scale;
147                }
148            }
149            ViewportEvent::ModifiersChanged(mods) => {
150                self.modifiers = mods;
151            }
152            ViewportEvent::Key { key, state, repeat } => {
153                // Only process key events when the viewport is focused
154                if !self.ctx.focused {
155                    return;
156                }
157                match state {
158                    ButtonState::Pressed => {
159                        if !repeat {
160                            self.keys_pressed.insert(key);
161                        }
162                        self.keys_held.insert(key);
163                    }
164                    ButtonState::Released => {
165                        self.keys_held.remove(&key);
166                    }
167                }
168            }
169            ViewportEvent::Character(c) => {
170                // Only accept characters that are valid in a numeric expression.
171                // The app is responsible for only pushing this event while a
172                // manipulation session is active (see ViewportEvent::Character docs).
173                if c.is_ascii_digit() || c == '.' || c == '-' {
174                    self.typed_chars.push(c);
175                }
176            }
177            ViewportEvent::PointerLeft => {
178                self.pointer_pos = None;
179                // Release all buttons on pointer leave to avoid stuck state
180                for held in &mut self.button_held {
181                    *held = false;
182                }
183                for pos in &mut self.button_press_pos {
184                    *pos = None;
185                }
186            }
187            ViewportEvent::FocusLost => {
188                // Release all buttons and keys on focus loss
189                for held in &mut self.button_held {
190                    *held = false;
191                }
192                for pos in &mut self.button_press_pos {
193                    *pos = None;
194                }
195                self.keys_held.clear();
196                self.keys_pressed.clear();
197            }
198            ViewportEvent::TrackpadRotate(angle) => {
199                if self.ctx.hovered {
200                    self.rotate_gesture += angle;
201                }
202            }
203        }
204    }
205
206    /// Resolve accumulated events into an [`ActionFrame`].
207    ///
208    /// This does NOT reset state : call [`begin_frame`](Self::begin_frame) for that.
209    pub fn resolve(&self) -> ActionFrame {
210        let mut orbit = glam::Vec2::ZERO;
211        let mut pan = glam::Vec2::ZERO;
212        let mut zoom = 0.0f32;
213        let mut actions = std::collections::HashMap::new();
214
215        // Skip pointer/wheel gesture evaluation if viewport is not hovered
216        // (and no button is actively held from a press that started inside).
217        let any_held_with_press = self
218            .button_held
219            .iter()
220            .enumerate()
221            .any(|(i, &held)| held && self.button_press_pos[i].is_some());
222        let pointer_active = self.ctx.hovered || any_held_with_press;
223
224        for binding in &self.bindings {
225            match &binding.gesture {
226                ViewportGesture::Drag { button, modifiers } => {
227                    if !pointer_active {
228                        continue;
229                    }
230                    let idx = button_index(*button);
231                    let held = self.button_held[idx];
232                    let press_started = self.button_press_pos[idx].is_some();
233                    if held && press_started && modifiers.matches(self.modifiers) {
234                        let delta = self.drag_delta;
235                        match binding.action {
236                            Action::Orbit => {
237                                if orbit == glam::Vec2::ZERO {
238                                    orbit += delta;
239                                    actions
240                                        .entry(binding.action)
241                                        .or_insert(ResolvedActionState::Delta(delta));
242                                }
243                            }
244                            Action::Pan => {
245                                if pan == glam::Vec2::ZERO {
246                                    pan += delta;
247                                    actions
248                                        .entry(binding.action)
249                                        .or_insert(ResolvedActionState::Delta(delta));
250                                }
251                            }
252                            Action::Zoom => {
253                                if zoom == 0.0 {
254                                    zoom += delta.y;
255                                    actions
256                                        .entry(binding.action)
257                                        .or_insert(ResolvedActionState::Delta(delta));
258                                }
259                            }
260                            _ => {
261                                actions
262                                    .entry(binding.action)
263                                    .or_insert(ResolvedActionState::Delta(delta));
264                            }
265                        }
266                    }
267                }
268                ViewportGesture::WheelY { modifiers } => {
269                    if !pointer_active {
270                        continue;
271                    }
272                    if modifiers.matches(self.modifiers) && self.wheel_delta.y != 0.0 {
273                        let y = self.wheel_delta.y;
274                        match binding.action {
275                            Action::Zoom => zoom += y,
276                            Action::Orbit => orbit.y += y,
277                            Action::Pan => pan.y += y,
278                            _ => {}
279                        }
280                        actions
281                            .entry(binding.action)
282                            .or_insert(ResolvedActionState::Delta(glam::Vec2::new(0.0, y)));
283                    }
284                }
285                ViewportGesture::WheelXY { modifiers } => {
286                    if !pointer_active {
287                        continue;
288                    }
289                    if modifiers.matches(self.modifiers) && self.wheel_delta != glam::Vec2::ZERO {
290                        let delta = self.wheel_delta;
291                        match binding.action {
292                            Action::Orbit => orbit += delta,
293                            Action::Pan => pan += delta,
294                            Action::Zoom => zoom += delta.y,
295                            _ => {}
296                        }
297                        actions
298                            .entry(binding.action)
299                            .or_insert(ResolvedActionState::Delta(delta));
300                    }
301                }
302                ViewportGesture::KeyPress { key, modifiers } => {
303                    if self.keys_pressed.contains(key) && modifiers.matches(self.modifiers) {
304                        actions
305                            .entry(binding.action)
306                            .or_insert(ResolvedActionState::Pressed);
307                    }
308                }
309                ViewportGesture::KeyHold { key, modifiers } => {
310                    if self.keys_held.contains(key) && modifiers.matches(self.modifiers) {
311                        actions
312                            .entry(binding.action)
313                            .or_insert(ResolvedActionState::Held);
314                    }
315                }
316            }
317        }
318
319        ActionFrame {
320            navigation: NavigationActions {
321                orbit,
322                pan,
323                zoom,
324                twist: self.rotate_gesture,
325            },
326            actions,
327            typed_chars: self.typed_chars.clone(),
328        }
329    }
330
331    /// Current modifier state.
332    pub fn modifiers(&self) -> Modifiers {
333        self.modifiers
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use crate::interaction::input::event::ButtonState;
341    use crate::interaction::input::preset::viewport_all_bindings;
342
343    fn focused_ctx() -> ViewportContext {
344        ViewportContext {
345            hovered: true,
346            focused: true,
347            viewport_size: [800.0, 600.0],
348        }
349    }
350
351    #[test]
352    fn key_press_fires_once_then_clears() {
353        let mut input = ViewportInput::new(viewport_all_bindings());
354        input.begin_frame(focused_ctx());
355        input.push_event(ViewportEvent::Key {
356            key: KeyCode::F,
357            state: ButtonState::Pressed,
358            repeat: false,
359        });
360        let frame = input.resolve();
361        assert!(
362            frame.is_active(Action::FocusObject),
363            "FocusObject should be active on first frame"
364        );
365
366        // Second frame without a new press should not fire
367        input.begin_frame(focused_ctx());
368        let frame2 = input.resolve();
369        assert!(
370            !frame2.is_active(Action::FocusObject),
371            "FocusObject should not be active on second frame"
372        );
373    }
374
375    #[test]
376    fn key_ignored_when_not_focused() {
377        let mut input = ViewportInput::new(viewport_all_bindings());
378        input.begin_frame(ViewportContext {
379            hovered: true,
380            focused: false,
381            viewport_size: [800.0, 600.0],
382        });
383        input.push_event(ViewportEvent::Key {
384            key: KeyCode::F,
385            state: ButtonState::Pressed,
386            repeat: false,
387        });
388        let frame = input.resolve();
389        assert!(
390            !frame.is_active(Action::FocusObject),
391            "key should be ignored without focus"
392        );
393    }
394
395    #[test]
396    fn resolve_no_events_is_zero() {
397        let mut input = ViewportInput::new(viewport_all_bindings());
398        input.begin_frame(focused_ctx());
399        let frame = input.resolve();
400        assert_eq!(frame.navigation.orbit, glam::Vec2::ZERO);
401        assert_eq!(frame.navigation.pan, glam::Vec2::ZERO);
402        assert_eq!(frame.navigation.zoom, 0.0);
403        assert_eq!(frame.navigation.twist, 0.0);
404        assert!(frame.actions.is_empty());
405    }
406
407    #[test]
408    fn scroll_produces_zoom() {
409        let mut input = ViewportInput::new(viewport_all_bindings());
410        input.begin_frame(focused_ctx());
411        input.push_event(ViewportEvent::Wheel {
412            delta: glam::Vec2::new(0.0, 3.0),
413            units: ScrollUnits::Lines,
414        });
415        let frame = input.resolve();
416        // Lines are scaled by PIXELS_PER_LINE (28.0), so zoom = 3 * 28 = 84
417        assert!((frame.navigation.zoom - 84.0).abs() < 1e-3);
418    }
419
420    #[test]
421    fn scroll_pixel_units_no_scaling() {
422        let mut input = ViewportInput::new(viewport_all_bindings());
423        input.begin_frame(focused_ctx());
424        input.push_event(ViewportEvent::Wheel {
425            delta: glam::Vec2::new(0.0, 10.0),
426            units: ScrollUnits::Pixels,
427        });
428        let frame = input.resolve();
429        assert!((frame.navigation.zoom - 10.0).abs() < 1e-3);
430    }
431
432    #[test]
433    fn scroll_ignored_when_not_hovered() {
434        let mut input = ViewportInput::new(viewport_all_bindings());
435        input.begin_frame(ViewportContext {
436            hovered: false,
437            focused: true,
438            viewport_size: [800.0, 600.0],
439        });
440        input.push_event(ViewportEvent::Wheel {
441            delta: glam::Vec2::new(0.0, 5.0),
442            units: ScrollUnits::Lines,
443        });
444        let frame = input.resolve();
445        assert_eq!(frame.navigation.zoom, 0.0);
446    }
447
448    #[test]
449    fn right_drag_produces_pan() {
450        let mut input = ViewportInput::new(viewport_all_bindings());
451        input.begin_frame(focused_ctx());
452        // Move pointer to a position, press right button, then move
453        input.push_event(ViewportEvent::PointerMoved {
454            position: glam::Vec2::new(100.0, 100.0),
455        });
456        input.push_event(ViewportEvent::MouseButton {
457            button: MouseButton::Right,
458            state: ButtonState::Pressed,
459        });
460        input.push_event(ViewportEvent::PointerMoved {
461            position: glam::Vec2::new(110.0, 105.0),
462        });
463        let frame = input.resolve();
464        assert!((frame.navigation.pan.x - 10.0).abs() < 1e-3);
465        assert!((frame.navigation.pan.y - 5.0).abs() < 1e-3);
466    }
467
468    #[test]
469    fn pointer_move_without_button_no_drag() {
470        let mut input = ViewportInput::new(viewport_all_bindings());
471        input.begin_frame(focused_ctx());
472        input.push_event(ViewportEvent::PointerMoved {
473            position: glam::Vec2::new(100.0, 100.0),
474        });
475        input.push_event(ViewportEvent::PointerMoved {
476            position: glam::Vec2::new(200.0, 200.0),
477        });
478        let frame = input.resolve();
479        assert_eq!(frame.navigation.orbit, glam::Vec2::ZERO);
480        assert_eq!(frame.navigation.pan, glam::Vec2::ZERO);
481    }
482
483    #[test]
484    fn begin_frame_resets_accumulators() {
485        let mut input = ViewportInput::new(viewport_all_bindings());
486        input.begin_frame(focused_ctx());
487        input.push_event(ViewportEvent::Wheel {
488            delta: glam::Vec2::new(0.0, 5.0),
489            units: ScrollUnits::Pixels,
490        });
491        // First resolve should have zoom
492        let frame1 = input.resolve();
493        assert!(frame1.navigation.zoom != 0.0);
494        // begin_frame resets accumulators
495        input.begin_frame(focused_ctx());
496        let frame2 = input.resolve();
497        assert_eq!(frame2.navigation.zoom, 0.0);
498    }
499
500    #[test]
501    fn pointer_left_releases_buttons() {
502        let mut input = ViewportInput::new(viewport_all_bindings());
503        input.begin_frame(focused_ctx());
504        input.push_event(ViewportEvent::PointerMoved {
505            position: glam::Vec2::new(100.0, 100.0),
506        });
507        input.push_event(ViewportEvent::MouseButton {
508            button: MouseButton::Right,
509            state: ButtonState::Pressed,
510        });
511        input.push_event(ViewportEvent::PointerLeft);
512        // Now move again and check no drag delta accumulates
513        input.push_event(ViewportEvent::PointerMoved {
514            position: glam::Vec2::new(200.0, 200.0),
515        });
516        let frame = input.resolve();
517        assert_eq!(frame.navigation.pan, glam::Vec2::ZERO);
518    }
519
520    #[test]
521    fn focus_lost_clears_keys() {
522        let mut input = ViewportInput::new(viewport_all_bindings());
523        input.begin_frame(focused_ctx());
524        input.push_event(ViewportEvent::Key {
525            key: KeyCode::W,
526            state: ButtonState::Pressed,
527            repeat: false,
528        });
529        input.push_event(ViewportEvent::FocusLost);
530        let frame = input.resolve();
531        // FlyForward is bound to W hold; after FocusLost, keys_held is cleared
532        assert!(
533            !frame.is_active(Action::FlyForward),
534            "FlyForward should not be active after focus lost"
535        );
536    }
537
538    #[test]
539    fn character_event_populates_typed_chars() {
540        let mut input = ViewportInput::new(viewport_all_bindings());
541        input.begin_frame(focused_ctx());
542        input.push_event(ViewportEvent::Character('3'));
543        input.push_event(ViewportEvent::Character('.'));
544        input.push_event(ViewportEvent::Character('5'));
545        input.push_event(ViewportEvent::Character('a')); // filtered out
546        let frame = input.resolve();
547        assert_eq!(frame.typed_chars, vec!['3', '.', '5']);
548    }
549
550    #[test]
551    fn trackpad_rotate_accumulates_twist() {
552        let mut input = ViewportInput::new(viewport_all_bindings());
553        input.begin_frame(focused_ctx());
554        input.push_event(ViewportEvent::TrackpadRotate(0.1));
555        input.push_event(ViewportEvent::TrackpadRotate(0.2));
556        let frame = input.resolve();
557        assert!((frame.navigation.twist - 0.3).abs() < 1e-5);
558    }
559
560    #[test]
561    fn key_hold_active_every_frame() {
562        let mut input = ViewportInput::new(viewport_all_bindings());
563        input.begin_frame(focused_ctx());
564        input.push_event(ViewportEvent::Key {
565            key: KeyCode::W,
566            state: ButtonState::Pressed,
567            repeat: false,
568        });
569        let frame1 = input.resolve();
570        assert!(frame1.is_active(Action::FlyForward));
571        // Next frame: key is still held (no release event), so KeyHold should still fire
572        input.begin_frame(focused_ctx());
573        let frame2 = input.resolve();
574        assert!(
575            frame2.is_active(Action::FlyForward),
576            "FlyForward should persist while key is held"
577        );
578    }
579
580    #[test]
581    fn key_release_stops_hold() {
582        let mut input = ViewportInput::new(viewport_all_bindings());
583        input.begin_frame(focused_ctx());
584        input.push_event(ViewportEvent::Key {
585            key: KeyCode::W,
586            state: ButtonState::Pressed,
587            repeat: false,
588        });
589        let frame1 = input.resolve();
590        assert!(frame1.is_active(Action::FlyForward));
591        input.begin_frame(focused_ctx());
592        input.push_event(ViewportEvent::Key {
593            key: KeyCode::W,
594            state: ButtonState::Released,
595            repeat: false,
596        });
597        let frame2 = input.resolve();
598        assert!(
599            !frame2.is_active(Action::FlyForward),
600            "FlyForward should stop after key release"
601        );
602    }
603
604    #[test]
605    fn modifiers_changed_affects_bindings() {
606        let mut input = ViewportInput::new(viewport_all_bindings());
607        input.begin_frame(focused_ctx());
608        // Press Shift modifier, then press X -> should fire ExcludeX (Shift+X)
609        input.push_event(ViewportEvent::ModifiersChanged(Modifiers::SHIFT));
610        input.push_event(ViewportEvent::Key {
611            key: KeyCode::X,
612            state: ButtonState::Pressed,
613            repeat: false,
614        });
615        let frame = input.resolve();
616        assert!(
617            frame.is_active(Action::ExcludeX),
618            "Shift+X should fire ExcludeX"
619        );
620    }
621
622    #[test]
623    fn repeat_key_does_not_fire_press() {
624        let mut input = ViewportInput::new(viewport_all_bindings());
625        input.begin_frame(focused_ctx());
626        input.push_event(ViewportEvent::Key {
627            key: KeyCode::G,
628            state: ButtonState::Pressed,
629            repeat: true,
630        });
631        let frame = input.resolve();
632        // BeginMove is a KeyPress binding; repeat should not trigger it
633        assert!(
634            !frame.is_active(Action::BeginMove),
635            "repeat should not fire KeyPress bindings"
636        );
637    }
638
639    #[test]
640    fn middle_drag_shift_produces_pan() {
641        let mut input = ViewportInput::new(viewport_all_bindings());
642        input.begin_frame(focused_ctx());
643        input.push_event(ViewportEvent::PointerMoved {
644            position: glam::Vec2::new(50.0, 50.0),
645        });
646        input.push_event(ViewportEvent::ModifiersChanged(Modifiers::SHIFT));
647        input.push_event(ViewportEvent::MouseButton {
648            button: MouseButton::Middle,
649            state: ButtonState::Pressed,
650        });
651        input.push_event(ViewportEvent::PointerMoved {
652            position: glam::Vec2::new(60.0, 55.0),
653        });
654        let frame = input.resolve();
655        assert!((frame.navigation.pan.x - 10.0).abs() < 1e-3);
656        assert!((frame.navigation.pan.y - 5.0).abs() < 1e-3);
657    }
658}