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}