Skip to main content

loam_app/session/
input.rs

1use web_time::Instant;
2
3use loam_runtime::{
4    ActionEvent, Bindings, Input, Key, Pointer, PointerButton, PointerPhase as RuntimePointerPhase,
5};
6
7use crate::wasm::input_queue::{InputMessage, PointerPhase};
8
9pub struct InputMap {
10    input: Input,
11    spare: Input,
12    held_keys: Vec<(Key, loam_runtime::ActionId)>,
13    width: u32,
14    height: u32,
15    scale: f32,
16    display: Option<[f32; 2]>,
17    cursor: [f32; 2],
18    look: [f32; 2],
19    cursor_locked: bool,
20    mouse_buttons: [bool; 3],
21    active_touches: Vec<u32>,
22    started: Instant,
23    clock_offset: f64,
24    latest_time: f64,
25}
26
27#[cfg(any(all(target_arch = "wasm32", feature = "egui"), test))]
28pub(crate) struct TouchCapture {
29    active: Vec<CapturedTouch>,
30    pointer: Option<u64>,
31}
32
33#[cfg(any(all(target_arch = "wasm32", feature = "egui"), test))]
34struct CapturedTouch {
35    id: u64,
36    captured: bool,
37    pos: [f32; 2],
38}
39
40#[cfg(any(all(target_arch = "wasm32", feature = "egui"), test))]
41impl Default for TouchCapture {
42    fn default() -> Self {
43        Self {
44            active: Vec::with_capacity(8),
45            pointer: None,
46        }
47    }
48}
49
50#[cfg(any(all(target_arch = "wasm32", feature = "egui"), test))]
51impl TouchCapture {
52    pub(crate) fn route(
53        &mut self,
54        id: u64,
55        phase: PointerPhase,
56        over_ui: bool,
57        pos: [f32; 2],
58    ) -> bool {
59        match phase {
60            PointerPhase::Down => {
61                if let Some(touch) = self.active.iter_mut().find(|touch| touch.id == id) {
62                    touch.pos = pos;
63                    return touch.captured;
64                }
65                if over_ui && self.pointer.is_none() {
66                    self.pointer = Some(id);
67                }
68                self.active.push(CapturedTouch {
69                    id,
70                    captured: over_ui,
71                    pos,
72                });
73                over_ui
74            }
75            PointerPhase::Move => {
76                let Some(touch) = self.active.iter_mut().find(|touch| touch.id == id) else {
77                    return false;
78                };
79                touch.pos = pos;
80                touch.captured
81            }
82            PointerPhase::Up | PointerPhase::Cancel => {
83                let captured = self
84                    .active
85                    .iter()
86                    .position(|touch| touch.id == id)
87                    .map(|index| self.active.remove(index).captured)
88                    .unwrap_or(false);
89                if self.pointer == Some(id) {
90                    self.pointer = None;
91                }
92                captured
93            }
94        }
95    }
96
97    #[cfg(feature = "egui")]
98    pub(super) fn cancel_all(&mut self) -> impl Iterator<Item = (u64, [f32; 2])> + '_ {
99        self.pointer = None;
100        self.active
101            .drain(..)
102            .filter_map(|touch| touch.captured.then_some((touch.id, touch.pos)))
103    }
104
105    #[cfg(feature = "egui")]
106    pub(crate) fn is_pointer(&self, id: u64) -> bool {
107        self.pointer == Some(id)
108    }
109}
110
111impl Default for InputMap {
112    fn default() -> Self {
113        Self {
114            input: Input::default(),
115            spare: Input::default(),
116            held_keys: Vec::new(),
117            width: 1,
118            height: 1,
119            scale: 1.0,
120            display: None,
121            cursor: [0.0; 2],
122            look: [0.0; 2],
123            cursor_locked: false,
124            mouse_buttons: [false; 3],
125            active_touches: Vec::with_capacity(8),
126            started: Instant::now(),
127            clock_offset: 0.0,
128            latest_time: 0.0,
129        }
130    }
131}
132
133impl InputMap {
134    pub fn resize(&mut self, width: u32, height: u32, scale: f32) {
135        self.width = width.max(1);
136        self.height = height.max(1);
137        if scale.is_finite() && scale > 0.0 {
138            self.scale = scale;
139        }
140    }
141
142    /// The CSS size the canvas is shown at, which leads the physical size while a resize settles.
143    pub fn set_display(&mut self, width: f32, height: f32) {
144        if width.is_finite() && height.is_finite() && width >= 1.0 && height >= 1.0 {
145            self.display = Some([width, height]);
146        }
147    }
148
149    pub fn display_aspect(&self) -> Option<f32> {
150        self.display.map(|[width, height]| width / height)
151    }
152
153    fn css_size(&self) -> [f32; 2] {
154        self.display.unwrap_or([
155            self.width as f32 / self.scale,
156            self.height as f32 / self.scale,
157        ])
158    }
159
160    pub fn size(&self) -> (u32, u32) {
161        (self.width, self.height)
162    }
163
164    pub fn scale(&self) -> f32 {
165        self.scale
166    }
167
168    pub fn cursor(&self) -> [f32; 2] {
169        self.cursor
170    }
171
172    pub fn cursor_locked(&self) -> bool {
173        self.cursor_locked
174    }
175
176    pub fn set_cursor_locked(&mut self, locked: bool) {
177        if self.cursor_locked == locked {
178            return;
179        }
180        self.cursor_locked = locked;
181        self.look = [0.0; 2];
182        self.release_pointers();
183    }
184
185    pub fn ndc(&self, physical_x: f64, physical_y: f64) -> [f32; 2] {
186        [
187            (physical_x as f32 / self.width as f32) * 2.0 - 1.0,
188            1.0 - (physical_y as f32 / self.height as f32) * 2.0,
189        ]
190    }
191
192    pub fn css_ndc(&self, css_x: f32, css_y: f32) -> [f32; 2] {
193        let [width, height] = self.css_size();
194        [css_x / width * 2.0 - 1.0, 1.0 - css_y / height * 2.0]
195    }
196
197    pub fn action(&mut self, bindings: &Bindings, key: Key, pressed: bool) {
198        let held = self.held_keys.iter().position(|(held, _)| *held == key);
199        if pressed {
200            if held.is_some() {
201                return;
202            }
203            let Some(action) = bindings.action(key) else {
204                return;
205            };
206            let starts_action = !self
207                .held_keys
208                .iter()
209                .any(|(_, held_action)| *held_action == action);
210            self.held_keys.push((key, action));
211            if starts_action {
212                self.input.actions.push(ActionEvent {
213                    action,
214                    pressed: true,
215                });
216            }
217        } else {
218            let Some(held) = held else {
219                return;
220            };
221            let (_, action) = self.held_keys.remove(held);
222            if !self
223                .held_keys
224                .iter()
225                .any(|(_, held_action)| *held_action == action)
226            {
227                self.input.actions.push(ActionEvent {
228                    action,
229                    pressed: false,
230                });
231            }
232        }
233    }
234
235    pub(crate) fn host_action(
236        &mut self,
237        bindings: &Bindings,
238        key: Key,
239        pressed: bool,
240        consumed: bool,
241    ) {
242        if !consumed || !pressed {
243            self.action(bindings, key, pressed);
244        }
245    }
246
247    pub fn release_all(&mut self) {
248        while let Some((_, action)) = self.held_keys.pop() {
249            if !self
250                .held_keys
251                .iter()
252                .any(|(_, held_action)| *held_action == action)
253            {
254                self.input.actions.push(ActionEvent {
255                    action,
256                    pressed: false,
257                });
258            }
259        }
260        self.release_pointers();
261    }
262
263    fn now(&self) -> f64 {
264        (self.started.elapsed().as_secs_f64() + self.clock_offset).max(self.latest_time)
265    }
266
267    fn sync_clock(&mut self, time: f64) {
268        self.clock_offset = time - self.started.elapsed().as_secs_f64();
269    }
270
271    fn release_pointers(&mut self) {
272        let time = self.now();
273        for button in [
274            PointerButton::Primary,
275            PointerButton::Secondary,
276            PointerButton::Middle,
277        ] {
278            let index = Self::button_index(button);
279            if self.mouse_buttons[index] {
280                self.push(
281                    self.cursor,
282                    [0.0; 2],
283                    Some(button),
284                    RuntimePointerPhase::Cancelled,
285                    time,
286                );
287            }
288        }
289        self.mouse_buttons = [false; 3];
290        for id in std::mem::take(&mut self.active_touches) {
291            self.push_with(
292                id,
293                self.cursor,
294                [0.0; 2],
295                Some(PointerButton::Primary),
296                RuntimePointerPhase::Cancelled,
297                time,
298            );
299        }
300    }
301
302    pub fn raw_motion(&mut self, physical_dx: f32, physical_dy: f32) {
303        if !self.cursor_locked {
304            return;
305        }
306        self.look[0] += physical_dx / self.scale;
307        self.look[1] -= physical_dy / self.scale;
308    }
309
310    pub fn moved(&mut self, ndc: [f32; 2]) {
311        self.moved_at(ndc, self.now());
312    }
313
314    fn moved_at(&mut self, ndc: [f32; 2], time: f64) {
315        let delta = [ndc[0] - self.cursor[0], ndc[1] - self.cursor[1]];
316        self.cursor = ndc;
317        let mut moved = false;
318        for button in [
319            PointerButton::Primary,
320            PointerButton::Secondary,
321            PointerButton::Middle,
322        ] {
323            if self.mouse_buttons[Self::button_index(button)] {
324                self.push(ndc, delta, Some(button), RuntimePointerPhase::Moved, time);
325                moved = true;
326            }
327        }
328        if !moved {
329            self.push(ndc, delta, None, RuntimePointerPhase::Moved, time);
330        }
331    }
332
333    pub fn sync_mouse_buttons(&mut self, buttons: u8) {
334        self.mouse_buttons = [buttons & 1 != 0, buttons & 2 != 0, buttons & 4 != 0];
335    }
336
337    pub fn button(&mut self, ndc: [f32; 2], button: PointerButton, pressed: bool) {
338        self.button_at(ndc, button, pressed, self.now());
339    }
340
341    fn button_at(&mut self, ndc: [f32; 2], button: PointerButton, pressed: bool, time: f64) {
342        self.cursor = ndc;
343        self.mouse_buttons[Self::button_index(button)] = pressed;
344        let phase = if pressed {
345            RuntimePointerPhase::Began
346        } else {
347            RuntimePointerPhase::Ended
348        };
349        self.push(ndc, [0.0; 2], Some(button), phase, time);
350    }
351
352    #[cfg(any(not(target_arch = "wasm32"), test))]
353    pub(crate) fn host_button(
354        &mut self,
355        ndc: [f32; 2],
356        button: PointerButton,
357        pressed: bool,
358        consumed: bool,
359    ) {
360        let held = self.mouse_buttons[Self::button_index(button)];
361        if !consumed || (!pressed && held) {
362            self.button(ndc, button, pressed);
363        }
364    }
365
366    pub fn touch(&mut self, id: u32, ndc: [f32; 2], phase: RuntimePointerPhase) {
367        self.touch_at(id, ndc, phase, self.now());
368    }
369
370    fn touch_at(&mut self, id: u32, ndc: [f32; 2], phase: RuntimePointerPhase, time: f64) {
371        let delta = match phase {
372            RuntimePointerPhase::Moved => [ndc[0] - self.cursor[0], ndc[1] - self.cursor[1]],
373            _ => [0.0; 2],
374        };
375        self.cursor = ndc;
376        self.push_with(id, ndc, delta, Some(PointerButton::Primary), phase, time);
377    }
378
379    fn host_touch_at(
380        &mut self,
381        id: u32,
382        ndc: [f32; 2],
383        phase: RuntimePointerPhase,
384        consumed: bool,
385        time: f64,
386    ) {
387        let held = self.active_touches.contains(&id);
388        match phase {
389            RuntimePointerPhase::Began if !consumed => {
390                if !held {
391                    self.active_touches.push(id);
392                }
393                self.touch_at(id, ndc, phase, time);
394            }
395            RuntimePointerPhase::Moved if !consumed => self.touch_at(id, ndc, phase, time),
396            RuntimePointerPhase::Ended | RuntimePointerPhase::Cancelled if !consumed || held => {
397                self.active_touches.retain(|active| *active != id);
398                self.touch_at(id, ndc, phase, time);
399            }
400            _ => {}
401        }
402    }
403
404    pub fn wheel(&mut self, delta: [f32; 2]) {
405        self.input.scroll[0] += delta[0];
406        self.input.scroll[1] += delta[1];
407    }
408
409    pub fn host_message(&mut self, topic: &str, values: &[f32]) {
410        self.input.host.push(topic, values);
411    }
412
413    fn button_index(button: PointerButton) -> usize {
414        match button {
415            PointerButton::Primary => 0,
416            PointerButton::Secondary => 1,
417            PointerButton::Middle => 2,
418        }
419    }
420
421    fn push(
422        &mut self,
423        ndc: [f32; 2],
424        delta: [f32; 2],
425        button: Option<PointerButton>,
426        phase: RuntimePointerPhase,
427        time: f64,
428    ) {
429        self.push_with(0, ndc, delta, button, phase, time);
430    }
431
432    fn push_with(
433        &mut self,
434        id: u32,
435        ndc: [f32; 2],
436        delta: [f32; 2],
437        button: Option<PointerButton>,
438        phase: RuntimePointerPhase,
439        time: f64,
440    ) {
441        self.latest_time = self.latest_time.max(time);
442        let [width, height] = self.css_size();
443        self.input.pointers.push(Pointer {
444            id,
445            button,
446            ndc,
447            delta: [delta[0] * width / 2.0, delta[1] * height / 2.0],
448            phase,
449            time,
450        });
451    }
452
453    pub fn take(&mut self) -> Input {
454        self.input.held.clear();
455        for (_, action) in &self.held_keys {
456            if !self.input.held.contains(action) {
457                self.input.held.push(*action);
458            }
459        }
460        self.input.look = std::mem::take(&mut self.look);
461        self.input.cursor_locked = self.cursor_locked;
462        self.input.time = self.now();
463        std::mem::replace(&mut self.input, std::mem::take(&mut self.spare))
464    }
465
466    pub fn reclaim(&mut self, mut reclaimed: Input) {
467        reclaimed.pointers.clear();
468        reclaimed.actions.clear();
469        reclaimed.held.clear();
470        reclaimed.scroll = [0.0; 2];
471        reclaimed.look = [0.0; 2];
472        reclaimed.cursor_locked = false;
473        reclaimed.time = 0.0;
474        reclaimed.host.clear();
475        self.spare = reclaimed;
476    }
477}
478
479#[cfg(not(target_arch = "wasm32"))]
480pub fn winit_key(event: &winit::event::KeyEvent) -> Option<Key> {
481    use winit::keyboard::{Key as Logical, NamedKey};
482
483    match &event.logical_key {
484        Logical::Named(NamedKey::Space) => Some(Key::Space),
485        Logical::Named(NamedKey::Escape) => Some(Key::Escape),
486        Logical::Character(text) => match text.chars().next() {
487            Some(letter) if letter.is_ascii_digit() => Some(Key::Digit(letter as u8 - b'0')),
488            Some(letter) => Some(Key::Letter(letter.to_ascii_lowercase())),
489            None => None,
490        },
491        _ => None,
492    }
493}
494
495#[cfg(not(target_arch = "wasm32"))]
496pub fn winit_alt(event: &winit::event::KeyEvent) -> Option<usize> {
497    use winit::keyboard::{KeyCode, PhysicalKey};
498
499    match event.physical_key {
500        PhysicalKey::Code(KeyCode::AltLeft) => Some(0),
501        PhysicalKey::Code(KeyCode::AltRight) => Some(1),
502        _ => None,
503    }
504}
505
506pub fn dom_key(code: &str) -> Option<Key> {
507    match code {
508        "Space" => Some(Key::Space),
509        "Escape" => Some(Key::Escape),
510        _ => {
511            let digit = code
512                .strip_prefix("Digit")
513                .or_else(|| code.strip_prefix("Numpad"));
514            if let Some(digit) = digit.and_then(|rest| rest.parse::<u8>().ok()) {
515                return Some(Key::Digit(digit));
516            }
517            let mut letters = code.strip_prefix("Key")?.chars();
518            let letter = letters.next().filter(char::is_ascii_alphabetic)?;
519            letters
520                .next()
521                .is_none()
522                .then(|| Key::Letter(letter.to_ascii_lowercase()))
523        }
524    }
525}
526
527fn dom_phase(phase: PointerPhase) -> RuntimePointerPhase {
528    match phase {
529        PointerPhase::Down => RuntimePointerPhase::Began,
530        PointerPhase::Move => RuntimePointerPhase::Moved,
531        PointerPhase::Up => RuntimePointerPhase::Ended,
532        PointerPhase::Cancel => RuntimePointerPhase::Cancelled,
533    }
534}
535
536pub fn apply(map: &mut InputMap, bindings: &Bindings, message: &InputMessage, consumed: bool) {
537    match message {
538        InputMessage::Resize { width, height, dpr } => map.resize(*width, *height, *dpr),
539        InputMessage::Viewport { width, height } => map.set_display(*width, *height),
540        InputMessage::MouseMove {
541            x,
542            y,
543            buttons,
544            dx,
545            dy,
546            time,
547        } => {
548            map.sync_clock(time.as_secs_f64());
549            if consumed {
550                return;
551            }
552            if map.cursor_locked() {
553                map.raw_motion(*dx * map.scale(), *dy * map.scale());
554            } else {
555                let ndc = map.css_ndc(*x, *y);
556                map.sync_mouse_buttons(*buttons);
557                map.moved_at(ndc, time.as_secs_f64());
558            }
559        }
560        InputMessage::MouseButton {
561            x,
562            y,
563            button,
564            pressed,
565            time,
566        } => {
567            map.sync_clock(time.as_secs_f64());
568            let button = match button {
569                0 => Some(PointerButton::Primary),
570                1 => Some(PointerButton::Middle),
571                2 => Some(PointerButton::Secondary),
572                _ => None,
573            };
574            if let Some(button) = button {
575                let ndc = map.css_ndc(*x, *y);
576                let held = map.mouse_buttons[InputMap::button_index(button)];
577                if !consumed || (!pressed && held) {
578                    map.button_at(ndc, button, *pressed, time.as_secs_f64());
579                }
580            }
581        }
582        InputMessage::Key { code, pressed, .. } => {
583            if let Some(key) = dom_key(code) {
584                map.host_action(bindings, key, *pressed, consumed);
585            }
586        }
587        InputMessage::Focus(false) => map.release_all(),
588        InputMessage::Pointer {
589            id,
590            x,
591            y,
592            phase,
593            time,
594        } => {
595            map.sync_clock(time.as_secs_f64());
596            let ndc = map.css_ndc(*x, *y);
597            map.host_touch_at(
598                *id as u32,
599                ndc,
600                dom_phase(*phase),
601                consumed,
602                time.as_secs_f64(),
603            );
604        }
605        InputMessage::MouseWheel { dx, dy } if !consumed => map.wheel([-*dx, -*dy]),
606        InputMessage::MouseWheel { .. } => {}
607        InputMessage::Host { topic, values } => map.host_message(topic, values),
608        InputMessage::Focus(true)
609        | InputMessage::Visibility(_)
610        | InputMessage::Start
611        | InputMessage::PointerLockChanged { .. } => {}
612    }
613}
614
615#[cfg(test)]
616mod tests {
617    use loam_runtime::{ActionId, Ctx, Phase, Session, SimConfig};
618
619    use super::*;
620
621    #[global_allocator]
622    static COUNTING_ALLOCATOR: loam_time::alloc::CountingAllocator<std::alloc::System> =
623        loam_time::alloc::CountingAllocator::new(std::alloc::System);
624
625    const WALK: ActionId = ActionId(0);
626
627    loam_runtime::stores! {
628        #[derive(Default)]
629        pub struct Watched {
630            walked: Value<bool>,
631        }
632    }
633
634    fn key_message(code: &str, pressed: bool) -> InputMessage {
635        InputMessage::Key {
636            code: code.to_string(),
637            key: code.to_lowercase(),
638            pressed,
639            repeat: false,
640            ctrl: false,
641            shift: false,
642            alt: false,
643            meta: false,
644        }
645    }
646
647    #[test]
648    fn a_browser_key_message_reaches_the_session_as_its_bound_action() {
649        let bindings = Bindings::new().key(Key::Letter('w'), WALK);
650        let mut session = Session::new(Watched::default(), SimConfig::default());
651        session.system(Phase::Dispatch, "watch", |ctx: Ctx<'_, Watched>| {
652            if ctx.input.pressed(WALK) {
653                ctx.app.walked.set(true);
654            }
655            Ok(())
656        });
657
658        let mut map = InputMap::default();
659        apply(
660            &mut map,
661            &bindings,
662            &InputMessage::Resize {
663                width: 800,
664                height: 600,
665                dpr: 2.0,
666            },
667            false,
668        );
669        apply(&mut map, &bindings, &key_message("KeyW", true), false);
670        session.boundary(map.take()).expect("boundary");
671
672        assert!(
673            *session.app.walked.get(),
674            "the DOM key code never became the action bound to it"
675        );
676    }
677
678    #[test]
679    fn the_boundary_stamp_follows_the_pointer_clock_of_host_messages() {
680        let bindings = Bindings::new();
681        let mut map = InputMap::default();
682        map.resize(800, 600, 1.0);
683        apply(
684            &mut map,
685            &bindings,
686            &InputMessage::MouseMove {
687                x: 10.0,
688                y: 10.0,
689                buttons: 0,
690                dx: 0.0,
691                dy: 0.0,
692                time: std::time::Duration::from_secs(100),
693            },
694            false,
695        );
696        let input = map.take();
697        let pointer = input.pointers[0].time;
698        assert!(
699            pointer == 100.0 && input.time >= pointer && input.time < pointer + 1.0,
700            "the boundary was stamped {} on a clock where the pointer read {pointer}",
701            input.time
702        );
703    }
704
705    #[test]
706    fn a_warmed_frame_of_host_input_conversion_asks_the_allocator_for_nothing() {
707        let bindings = Bindings::new().key(Key::Letter('w'), WALK);
708        let mut session = Session::new(Watched::default(), SimConfig::default());
709        let mut map = InputMap::default();
710        map.resize(800, 600, 1.0);
711        let cycle = |map: &mut InputMap, session: &mut Session<Watched>| {
712            map.action(&bindings, Key::Letter('w'), true);
713            map.button([0.1, 0.2], PointerButton::Primary, true);
714            map.moved([0.2, 0.2]);
715            map.button([0.2, 0.2], PointerButton::Primary, false);
716            map.action(&bindings, Key::Letter('w'), false);
717            map.host_message("scroll", &[0.5]);
718            let input = map.take();
719            session.boundary(input).expect("boundary");
720            session.tick().expect("tick");
721            map.reclaim(session.take_input());
722        };
723        for _ in 0..8 {
724            cycle(&mut map, &mut session);
725        }
726
727        let bytes = loam_time::alloc::bytes_allocated_by(|| {
728            for _ in 0..16 {
729                cycle(&mut map, &mut session);
730            }
731        })
732        .expect("the counting allocator is installed");
733        assert_eq!(
734            bytes, 0,
735            "16 warmed frames of input asked the allocator for {bytes} bytes"
736        );
737    }
738
739    #[test]
740    fn a_reclaimed_input_carries_no_host_messages_from_the_previous_boundary() {
741        let mut map = InputMap::default();
742        map.host_message("reset", &[]);
743        let first = map.take();
744        assert_eq!(first.host.iter().count(), 1);
745
746        map.reclaim(first);
747        let second = map.take();
748        map.reclaim(second);
749        let reused = map.take();
750        assert!(reused.host.iter().next().is_none());
751    }
752
753    #[test]
754    fn pointers_map_to_the_shown_canvas_while_its_physical_size_lags() {
755        let mut map = InputMap::default();
756        map.resize(1600, 900, 1.0);
757        map.set_display(2560.0, 900.0);
758        assert_eq!(map.css_ndc(2560.0, 450.0), [1.0, 0.0]);
759        assert_eq!(map.css_ndc(640.0, 0.0), [-0.5, 1.0]);
760        assert_eq!(map.display_aspect(), Some(2560.0 / 900.0));
761    }
762
763    #[test]
764    fn camera_turns_do_not_depend_on_viewport_shape_or_pixel_scale() {
765        for (width, height, scale) in [(640, 480, 1.0), (1280, 480, 1.0), (1280, 960, 2.0)] {
766            let mut map = InputMap::default();
767            map.resize(width, height, scale);
768            map.button(map.css_ndc(50.0, 50.0), PointerButton::Secondary, true);
769            map.moved(map.css_ndc(70.0, 70.0));
770            let input = map.take();
771            let mut orbit = crate::session::camera::Orbit::around([0.0; 3], 5.0);
772            orbit.drag(input.drag(PointerButton::Secondary));
773            assert!((orbit.yaw + 0.12).abs() < 1e-6);
774            assert!((orbit.pitch + 0.12).abs() < 1e-6);
775
776            map.set_cursor_locked(true);
777            map.raw_motion(20.0 * scale, 20.0 * scale);
778            let mut camera = crate::session::camera::FreeCamera::default();
779            camera.look(map.take().look);
780            let [x, y, z] = camera.eye.forward;
781            assert!(((-x).atan2(-z) + 0.04).abs() < 1e-6);
782            assert!((y.asin() + 0.04).abs() < 1e-6);
783        }
784    }
785
786    #[test]
787    fn raw_look_requires_a_confirmed_lock_and_survives_a_leased_input() {
788        let mut map = InputMap::default();
789        map.resize(800, 400, 2.0);
790        map.raw_motion(20.0, 10.0);
791        map.set_cursor_locked(true);
792        map.raw_motion(20.0, 10.0);
793
794        let first = map.take();
795        assert_eq!(first.look, [10.0, -5.0]);
796        assert!(first.cursor_locked);
797
798        map.raw_motion(40.0, -20.0);
799        map.reclaim(first);
800        let second = map.take();
801        assert_eq!(second.look, [20.0, 10.0]);
802    }
803
804    #[test]
805    fn a_consumed_release_clears_the_action_held_before_the_overlay_took_focus() {
806        let bindings = Bindings::new().key(Key::Letter('w'), WALK);
807        let mut map = InputMap::default();
808        map.host_action(&bindings, Key::Letter('w'), true, false);
809        let first = map.take();
810
811        map.host_action(&bindings, Key::Letter('w'), false, true);
812        map.reclaim(first);
813        let second = map.take();
814
815        assert!(second
816            .actions
817            .iter()
818            .any(|event| event.action == WALK && !event.pressed));
819        assert!(!second.held.contains(&WALK));
820    }
821
822    #[test]
823    fn consumed_pointer_releases_clear_scene_holds() {
824        let mut map = InputMap::default();
825        map.host_button([0.0; 2], PointerButton::Primary, true, false);
826        let first = map.take();
827        map.reclaim(first);
828
829        map.host_button([0.0; 2], PointerButton::Primary, false, true);
830        let released = map.take();
831
832        assert_eq!(released.pointers.len(), 1);
833        assert_eq!(released.pointers[0].phase, RuntimePointerPhase::Ended);
834    }
835
836    #[test]
837    fn browser_touch_capture_keeps_its_first_owner_and_source_times() {
838        let bindings = Bindings::new();
839        let mut map = InputMap::default();
840        map.resize(800, 600, 1.0);
841        let mut capture = TouchCapture::default();
842
843        for (message, over_ui) in [
844            (touch_message(7, PointerPhase::Down, 100, 40.0), true),
845            (touch_message(7, PointerPhase::Move, 120, 50.0), false),
846            (touch_message(7, PointerPhase::Up, 140, 60.0), false),
847        ] {
848            let InputMessage::Pointer {
849                id, x, y, phase, ..
850            } = &message
851            else {
852                unreachable!();
853            };
854            let consumed = capture.route(*id, *phase, over_ui, [*x, *y]);
855            apply(&mut map, &bindings, &message, consumed);
856        }
857        assert!(map.take().pointers.is_empty());
858
859        for (message, over_ui) in [
860            (touch_message(8, PointerPhase::Down, 200, 100.0), false),
861            (touch_message(8, PointerPhase::Move, 225, 110.0), true),
862            (touch_message(8, PointerPhase::Up, 250, 120.0), true),
863        ] {
864            let InputMessage::Pointer {
865                id, x, y, phase, ..
866            } = &message
867            else {
868                unreachable!();
869            };
870            let consumed = capture.route(*id, *phase, over_ui, [*x, *y]);
871            apply(&mut map, &bindings, &message, consumed);
872        }
873        let pointers = map.take().pointers;
874        assert_eq!(pointers.len(), 3);
875        assert_eq!(pointers[0].phase, RuntimePointerPhase::Began);
876        assert_eq!(pointers[1].phase, RuntimePointerPhase::Moved);
877        assert_eq!(pointers[2].phase, RuntimePointerPhase::Ended);
878        assert_eq!(
879            pointers
880                .iter()
881                .map(|pointer| pointer.time)
882                .collect::<Vec<_>>(),
883            [0.2, 0.225, 0.25]
884        );
885    }
886
887    fn touch_message(id: u64, phase: PointerPhase, millis: u64, x: f32) -> InputMessage {
888        InputMessage::Pointer {
889            id,
890            x,
891            y: 20.0,
892            phase,
893            time: std::time::Duration::from_millis(millis),
894        }
895    }
896}