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                    ScrollUnits::Pages => self.ctx.viewport_size[1].max(1.0),
144                };
145                // Only accumulate if viewport is hovered
146                if self.ctx.hovered {
147                    self.wheel_delta += delta * scale;
148                }
149            }
150            ViewportEvent::ModifiersChanged(mods) => {
151                self.modifiers = mods;
152            }
153            ViewportEvent::Key { key, state, repeat } => {
154                // Only process key events when the viewport is focused
155                if !self.ctx.focused {
156                    return;
157                }
158                match state {
159                    ButtonState::Pressed => {
160                        if !repeat {
161                            self.keys_pressed.insert(key);
162                        }
163                        self.keys_held.insert(key);
164                    }
165                    ButtonState::Released => {
166                        self.keys_held.remove(&key);
167                    }
168                }
169            }
170            ViewportEvent::Character(c) => {
171                // Only accept characters that are valid in a numeric expression.
172                // The app is responsible for only pushing this event while a
173                // manipulation session is active (see ViewportEvent::Character docs).
174                if c.is_ascii_digit() || c == '.' || c == '-' {
175                    self.typed_chars.push(c);
176                }
177            }
178            ViewportEvent::PointerLeft => {
179                self.pointer_pos = None;
180                // Release all buttons on pointer leave to avoid stuck state
181                for held in &mut self.button_held {
182                    *held = false;
183                }
184                for pos in &mut self.button_press_pos {
185                    *pos = None;
186                }
187            }
188            ViewportEvent::FocusLost => {
189                // Release all buttons and keys on focus loss
190                for held in &mut self.button_held {
191                    *held = false;
192                }
193                for pos in &mut self.button_press_pos {
194                    *pos = None;
195                }
196                self.keys_held.clear();
197                self.keys_pressed.clear();
198            }
199            ViewportEvent::TrackpadRotate(angle) => {
200                if self.ctx.hovered {
201                    self.rotate_gesture += angle;
202                }
203            }
204        }
205    }
206
207    /// Resolve accumulated events into an [`ActionFrame`].
208    ///
209    /// This does NOT reset state : call [`begin_frame`](Self::begin_frame) for that.
210    pub fn resolve(&self) -> ActionFrame {
211        let mut orbit = glam::Vec2::ZERO;
212        let mut pan = glam::Vec2::ZERO;
213        let mut zoom = 0.0f32;
214        let mut actions = std::collections::HashMap::new();
215
216        // Skip pointer/wheel gesture evaluation if viewport is not hovered
217        // (and no button is actively held from a press that started inside).
218        let any_held_with_press = self
219            .button_held
220            .iter()
221            .enumerate()
222            .any(|(i, &held)| held && self.button_press_pos[i].is_some());
223        let pointer_active = self.ctx.hovered || any_held_with_press;
224
225        for binding in &self.bindings {
226            match &binding.gesture {
227                ViewportGesture::Drag { button, modifiers } => {
228                    if !pointer_active {
229                        continue;
230                    }
231                    let idx = button_index(*button);
232                    let held = self.button_held[idx];
233                    let press_started = self.button_press_pos[idx].is_some();
234                    if held && press_started && modifiers.matches(self.modifiers) {
235                        let delta = self.drag_delta;
236                        match binding.action {
237                            Action::Orbit => {
238                                if orbit == glam::Vec2::ZERO {
239                                    orbit += delta;
240                                    actions
241                                        .entry(binding.action)
242                                        .or_insert(ResolvedActionState::Delta(delta));
243                                }
244                            }
245                            Action::Pan => {
246                                if pan == glam::Vec2::ZERO {
247                                    pan += delta;
248                                    actions
249                                        .entry(binding.action)
250                                        .or_insert(ResolvedActionState::Delta(delta));
251                                }
252                            }
253                            Action::Zoom => {
254                                if zoom == 0.0 {
255                                    zoom += delta.y;
256                                    actions
257                                        .entry(binding.action)
258                                        .or_insert(ResolvedActionState::Delta(delta));
259                                }
260                            }
261                            _ => {
262                                actions
263                                    .entry(binding.action)
264                                    .or_insert(ResolvedActionState::Delta(delta));
265                            }
266                        }
267                    }
268                }
269                ViewportGesture::WheelY { modifiers } => {
270                    if !pointer_active {
271                        continue;
272                    }
273                    if modifiers.matches(self.modifiers) && self.wheel_delta.y != 0.0 {
274                        let y = self.wheel_delta.y;
275                        match binding.action {
276                            Action::Zoom => zoom += y,
277                            Action::Orbit => orbit.y += y,
278                            Action::Pan => pan.y += y,
279                            _ => {}
280                        }
281                        actions
282                            .entry(binding.action)
283                            .or_insert(ResolvedActionState::Delta(glam::Vec2::new(0.0, y)));
284                    }
285                }
286                ViewportGesture::WheelXY { modifiers } => {
287                    if !pointer_active {
288                        continue;
289                    }
290                    if modifiers.matches(self.modifiers) && self.wheel_delta != glam::Vec2::ZERO {
291                        let delta = self.wheel_delta;
292                        match binding.action {
293                            Action::Orbit => orbit += delta,
294                            Action::Pan => pan += delta,
295                            Action::Zoom => zoom += delta.y,
296                            _ => {}
297                        }
298                        actions
299                            .entry(binding.action)
300                            .or_insert(ResolvedActionState::Delta(delta));
301                    }
302                }
303                ViewportGesture::KeyPress { key, modifiers } => {
304                    if self.keys_pressed.contains(key) && modifiers.matches(self.modifiers) {
305                        actions
306                            .entry(binding.action)
307                            .or_insert(ResolvedActionState::Pressed);
308                    }
309                }
310                ViewportGesture::KeyHold { key, modifiers } => {
311                    if self.keys_held.contains(key) && modifiers.matches(self.modifiers) {
312                        actions
313                            .entry(binding.action)
314                            .or_insert(ResolvedActionState::Held);
315                    }
316                }
317            }
318        }
319
320        ActionFrame {
321            navigation: NavigationActions {
322                orbit,
323                pan,
324                zoom,
325                twist: self.rotate_gesture,
326            },
327            actions,
328            typed_chars: self.typed_chars.clone(),
329        }
330    }
331
332    /// Current modifier state.
333    pub fn modifiers(&self) -> Modifiers {
334        self.modifiers
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use crate::interaction::input::event::ButtonState;
342    use crate::interaction::input::preset::viewport_all_bindings;
343
344    fn focused_ctx() -> ViewportContext {
345        ViewportContext {
346            hovered: true,
347            focused: true,
348            viewport_size: [800.0, 600.0],
349        }
350    }
351
352    #[test]
353    fn key_press_fires_once_then_clears() {
354        let mut input = ViewportInput::new(viewport_all_bindings());
355        input.begin_frame(focused_ctx());
356        input.push_event(ViewportEvent::Key {
357            key: KeyCode::F,
358            state: ButtonState::Pressed,
359            repeat: false,
360        });
361        let frame = input.resolve();
362        assert!(
363            frame.is_active(Action::FocusObject),
364            "FocusObject should be active on first frame"
365        );
366
367        // Second frame without a new press should not fire
368        input.begin_frame(focused_ctx());
369        let frame2 = input.resolve();
370        assert!(
371            !frame2.is_active(Action::FocusObject),
372            "FocusObject should not be active on second frame"
373        );
374    }
375
376    #[test]
377    fn key_ignored_when_not_focused() {
378        let mut input = ViewportInput::new(viewport_all_bindings());
379        input.begin_frame(ViewportContext {
380            hovered: true,
381            focused: false,
382            viewport_size: [800.0, 600.0],
383        });
384        input.push_event(ViewportEvent::Key {
385            key: KeyCode::F,
386            state: ButtonState::Pressed,
387            repeat: false,
388        });
389        let frame = input.resolve();
390        assert!(
391            !frame.is_active(Action::FocusObject),
392            "key should be ignored without focus"
393        );
394    }
395
396    #[test]
397    fn resolve_no_events_is_zero() {
398        let mut input = ViewportInput::new(viewport_all_bindings());
399        input.begin_frame(focused_ctx());
400        let frame = input.resolve();
401        assert_eq!(frame.navigation.orbit, glam::Vec2::ZERO);
402        assert_eq!(frame.navigation.pan, glam::Vec2::ZERO);
403        assert_eq!(frame.navigation.zoom, 0.0);
404        assert_eq!(frame.navigation.twist, 0.0);
405        assert!(frame.actions.is_empty());
406    }
407
408    #[test]
409    fn scroll_produces_zoom() {
410        let mut input = ViewportInput::new(viewport_all_bindings());
411        input.begin_frame(focused_ctx());
412        input.push_event(ViewportEvent::Wheel {
413            delta: glam::Vec2::new(0.0, 3.0),
414            units: ScrollUnits::Lines,
415        });
416        let frame = input.resolve();
417        // Lines are scaled by PIXELS_PER_LINE (28.0), so zoom = 3 * 28 = 84
418        assert!((frame.navigation.zoom - 84.0).abs() < 1e-3);
419    }
420
421    #[test]
422    fn scroll_pixel_units_no_scaling() {
423        let mut input = ViewportInput::new(viewport_all_bindings());
424        input.begin_frame(focused_ctx());
425        input.push_event(ViewportEvent::Wheel {
426            delta: glam::Vec2::new(0.0, 10.0),
427            units: ScrollUnits::Pixels,
428        });
429        let frame = input.resolve();
430        assert!((frame.navigation.zoom - 10.0).abs() < 1e-3);
431    }
432
433    #[test]
434    fn scroll_ignored_when_not_hovered() {
435        let mut input = ViewportInput::new(viewport_all_bindings());
436        input.begin_frame(ViewportContext {
437            hovered: false,
438            focused: true,
439            viewport_size: [800.0, 600.0],
440        });
441        input.push_event(ViewportEvent::Wheel {
442            delta: glam::Vec2::new(0.0, 5.0),
443            units: ScrollUnits::Lines,
444        });
445        let frame = input.resolve();
446        assert_eq!(frame.navigation.zoom, 0.0);
447    }
448
449    #[test]
450    fn right_drag_produces_pan() {
451        let mut input = ViewportInput::new(viewport_all_bindings());
452        input.begin_frame(focused_ctx());
453        // Move pointer to a position, press right button, then move
454        input.push_event(ViewportEvent::PointerMoved {
455            position: glam::Vec2::new(100.0, 100.0),
456        });
457        input.push_event(ViewportEvent::MouseButton {
458            button: MouseButton::Right,
459            state: ButtonState::Pressed,
460        });
461        input.push_event(ViewportEvent::PointerMoved {
462            position: glam::Vec2::new(110.0, 105.0),
463        });
464        let frame = input.resolve();
465        assert!((frame.navigation.pan.x - 10.0).abs() < 1e-3);
466        assert!((frame.navigation.pan.y - 5.0).abs() < 1e-3);
467    }
468
469    #[test]
470    fn pointer_move_without_button_no_drag() {
471        let mut input = ViewportInput::new(viewport_all_bindings());
472        input.begin_frame(focused_ctx());
473        input.push_event(ViewportEvent::PointerMoved {
474            position: glam::Vec2::new(100.0, 100.0),
475        });
476        input.push_event(ViewportEvent::PointerMoved {
477            position: glam::Vec2::new(200.0, 200.0),
478        });
479        let frame = input.resolve();
480        assert_eq!(frame.navigation.orbit, glam::Vec2::ZERO);
481        assert_eq!(frame.navigation.pan, glam::Vec2::ZERO);
482    }
483
484    #[test]
485    fn begin_frame_resets_accumulators() {
486        let mut input = ViewportInput::new(viewport_all_bindings());
487        input.begin_frame(focused_ctx());
488        input.push_event(ViewportEvent::Wheel {
489            delta: glam::Vec2::new(0.0, 5.0),
490            units: ScrollUnits::Pixels,
491        });
492        // First resolve should have zoom
493        let frame1 = input.resolve();
494        assert!(frame1.navigation.zoom != 0.0);
495        // begin_frame resets accumulators
496        input.begin_frame(focused_ctx());
497        let frame2 = input.resolve();
498        assert_eq!(frame2.navigation.zoom, 0.0);
499    }
500
501    #[test]
502    fn pointer_left_releases_buttons() {
503        let mut input = ViewportInput::new(viewport_all_bindings());
504        input.begin_frame(focused_ctx());
505        input.push_event(ViewportEvent::PointerMoved {
506            position: glam::Vec2::new(100.0, 100.0),
507        });
508        input.push_event(ViewportEvent::MouseButton {
509            button: MouseButton::Right,
510            state: ButtonState::Pressed,
511        });
512        input.push_event(ViewportEvent::PointerLeft);
513        // Now move again and check no drag delta accumulates
514        input.push_event(ViewportEvent::PointerMoved {
515            position: glam::Vec2::new(200.0, 200.0),
516        });
517        let frame = input.resolve();
518        assert_eq!(frame.navigation.pan, glam::Vec2::ZERO);
519    }
520
521    #[test]
522    fn focus_lost_clears_keys() {
523        let mut input = ViewportInput::new(viewport_all_bindings());
524        input.begin_frame(focused_ctx());
525        input.push_event(ViewportEvent::Key {
526            key: KeyCode::W,
527            state: ButtonState::Pressed,
528            repeat: false,
529        });
530        input.push_event(ViewportEvent::FocusLost);
531        let frame = input.resolve();
532        // FlyForward is bound to W hold; after FocusLost, keys_held is cleared
533        assert!(
534            !frame.is_active(Action::FlyForward),
535            "FlyForward should not be active after focus lost"
536        );
537    }
538
539    #[test]
540    fn character_event_populates_typed_chars() {
541        let mut input = ViewportInput::new(viewport_all_bindings());
542        input.begin_frame(focused_ctx());
543        input.push_event(ViewportEvent::Character('3'));
544        input.push_event(ViewportEvent::Character('.'));
545        input.push_event(ViewportEvent::Character('5'));
546        input.push_event(ViewportEvent::Character('a')); // filtered out
547        let frame = input.resolve();
548        assert_eq!(frame.typed_chars, vec!['3', '.', '5']);
549    }
550
551    #[test]
552    fn trackpad_rotate_accumulates_twist() {
553        let mut input = ViewportInput::new(viewport_all_bindings());
554        input.begin_frame(focused_ctx());
555        input.push_event(ViewportEvent::TrackpadRotate(0.1));
556        input.push_event(ViewportEvent::TrackpadRotate(0.2));
557        let frame = input.resolve();
558        assert!((frame.navigation.twist - 0.3).abs() < 1e-5);
559    }
560
561    #[test]
562    fn key_hold_active_every_frame() {
563        let mut input = ViewportInput::new(viewport_all_bindings());
564        input.begin_frame(focused_ctx());
565        input.push_event(ViewportEvent::Key {
566            key: KeyCode::W,
567            state: ButtonState::Pressed,
568            repeat: false,
569        });
570        let frame1 = input.resolve();
571        assert!(frame1.is_active(Action::FlyForward));
572        // Next frame: key is still held (no release event), so KeyHold should still fire
573        input.begin_frame(focused_ctx());
574        let frame2 = input.resolve();
575        assert!(
576            frame2.is_active(Action::FlyForward),
577            "FlyForward should persist while key is held"
578        );
579    }
580
581    #[test]
582    fn key_release_stops_hold() {
583        let mut input = ViewportInput::new(viewport_all_bindings());
584        input.begin_frame(focused_ctx());
585        input.push_event(ViewportEvent::Key {
586            key: KeyCode::W,
587            state: ButtonState::Pressed,
588            repeat: false,
589        });
590        let frame1 = input.resolve();
591        assert!(frame1.is_active(Action::FlyForward));
592        input.begin_frame(focused_ctx());
593        input.push_event(ViewportEvent::Key {
594            key: KeyCode::W,
595            state: ButtonState::Released,
596            repeat: false,
597        });
598        let frame2 = input.resolve();
599        assert!(
600            !frame2.is_active(Action::FlyForward),
601            "FlyForward should stop after key release"
602        );
603    }
604
605    #[test]
606    fn modifiers_changed_affects_bindings() {
607        let mut input = ViewportInput::new(viewport_all_bindings());
608        input.begin_frame(focused_ctx());
609        // Press Shift modifier, then press X -> should fire ExcludeX (Shift+X)
610        input.push_event(ViewportEvent::ModifiersChanged(Modifiers::SHIFT));
611        input.push_event(ViewportEvent::Key {
612            key: KeyCode::X,
613            state: ButtonState::Pressed,
614            repeat: false,
615        });
616        let frame = input.resolve();
617        assert!(
618            frame.is_active(Action::ExcludeX),
619            "Shift+X should fire ExcludeX"
620        );
621    }
622
623    #[test]
624    fn repeat_key_does_not_fire_press() {
625        let mut input = ViewportInput::new(viewport_all_bindings());
626        input.begin_frame(focused_ctx());
627        input.push_event(ViewportEvent::Key {
628            key: KeyCode::G,
629            state: ButtonState::Pressed,
630            repeat: true,
631        });
632        let frame = input.resolve();
633        // BeginMove is a KeyPress binding; repeat should not trigger it
634        assert!(
635            !frame.is_active(Action::BeginMove),
636            "repeat should not fire KeyPress bindings"
637        );
638    }
639
640    #[test]
641    fn middle_drag_shift_produces_pan() {
642        let mut input = ViewportInput::new(viewport_all_bindings());
643        input.begin_frame(focused_ctx());
644        input.push_event(ViewportEvent::PointerMoved {
645            position: glam::Vec2::new(50.0, 50.0),
646        });
647        input.push_event(ViewportEvent::ModifiersChanged(Modifiers::SHIFT));
648        input.push_event(ViewportEvent::MouseButton {
649            button: MouseButton::Middle,
650            state: ButtonState::Pressed,
651        });
652        input.push_event(ViewportEvent::PointerMoved {
653            position: glam::Vec2::new(60.0, 55.0),
654        });
655        let frame = input.resolve();
656        assert!((frame.navigation.pan.x - 10.0).abs() < 1e-3);
657        assert!((frame.navigation.pan.y - 5.0).abs() < 1e-3);
658    }
659}