ailloli_ui_openxr/
input.rs

1//! XR input adapter layer: map device/pose abstractions to `ailloli_ui_core::Event`.
2//!
3//! The types are intentionally host-agnostic:
4//! - the host can feed explicit samples (`OpenXrPointerSample`) each frame, or
5//! - implement `OpenXrPointerSource` for its own controller/ray/cursor type.
6//!
7//! `OpenXrInputMapper` keeps lightweight pointer state (`hover` + `pressed`) so that
8//! transition events (press/release) are generated even when a source drops and
9//! reappears across frames.
10
11use std::collections::HashMap;
12
13use ailloli_ui_core::event::pointer::{MouseButton, PointerEvent};
14use ailloli_ui_core::event::{Event, Modifiers, WheelDelta};
15use ailloli_ui_core::Point;
16
17/// Absolute hit result for one XR pointer source.
18///
19/// # Examples
20///
21/// ```
22/// use ailloli_ui_core::Point;
23/// use ailloli_ui_openxr::PointHit;
24///
25/// let hit = PointHit::new(Point::new(12.0, 8.0), Some(0.75));
26/// assert_eq!(hit.point, Point::new(12.0, 8.0));
27/// assert_eq!(hit.depth, Some(0.75));
28/// ```
29#[derive(Debug, Clone, Copy, PartialEq)]
30pub struct PointHit {
31    /// Intersection point in logical panel space.
32    pub point: Point,
33    /// Optional depth/range value for debug overlays (`None` for forward ray tests).
34    pub depth: Option<f32>,
35}
36
37impl PointHit {
38    /// Creates a logical-space hit with optional host-defined depth.
39    ///
40    /// `depth` is carried through unchanged; `None` means that the source did
41    /// not report a range value.
42    ///
43    /// # Examples
44    ///
45    /// ```
46    /// use ailloli_ui_core::Point;
47    /// use ailloli_ui_openxr::PointHit;
48    ///
49    /// let hit = PointHit::new(Point::new(1.0, 2.0), None);
50    /// assert_eq!(hit.depth, None);
51    /// ```
52    pub fn new(point: Point, depth: Option<f32>) -> Self {
53        Self { point, depth }
54    }
55}
56
57/// Hit state used by source/mappers.
58///
59/// # Examples
60///
61/// ```
62/// use ailloli_ui_openxr::OpenXrPointerHit;
63///
64/// assert_eq!(OpenXrPointerHit::Miss.point(), None);
65/// ```
66#[derive(Debug, Clone, Copy, PartialEq)]
67pub enum OpenXrPointerHit {
68    /// Pointer does not currently intersect target UI.
69    Miss,
70    /// Pointer intersects UI and can be routed with a logical 2D point.
71    Hit(PointHit),
72}
73
74impl OpenXrPointerHit {
75    /// Returns the logical hit point, or `None` for [`Self::Miss`].
76    ///
77    /// # Examples
78    ///
79    /// ```
80    /// use ailloli_ui_core::Point;
81    /// use ailloli_ui_openxr::{OpenXrPointerHit, PointHit};
82    ///
83    /// let hit = OpenXrPointerHit::Hit(PointHit::new(Point::new(3.0, 4.0), None));
84    /// assert_eq!(hit.point(), Some(Point::new(3.0, 4.0)));
85    /// ```
86    pub fn point(self) -> Option<Point> {
87        match self {
88            Self::Hit(PointHit { point, .. }) => Some(point),
89            Self::Miss => None,
90        }
91    }
92}
93
94/// Per-source raw pointer sample for one frame.
95///
96/// Scroll values are logical pixel deltas and are zero unless explicitly set.
97///
98/// # Examples
99///
100/// ```
101/// use ailloli_ui_openxr::{OpenXrPointerHit, OpenXrPointerSample};
102///
103/// let sample = OpenXrPointerSample::new(7, OpenXrPointerHit::Miss, false);
104/// assert_eq!(sample.source_id, 7);
105/// assert_eq!((sample.scroll_dx, sample.scroll_dy), (0.0, 0.0));
106/// ```
107#[derive(Debug, Clone, Copy, PartialEq)]
108pub struct OpenXrPointerSample {
109    /// Stable source id (right controller, left controller, head gaze, etc.).
110    pub source_id: u64,
111    /// Pointer hit location in logical UI space, or [`OpenXrPointerHit::Miss`].
112    pub hit: OpenXrPointerHit,
113    /// Primary button (trigger/click/trigger-like action).
114    pub trigger_pressed: bool,
115    /// Horizontal wheel-like delta in logical pixels.
116    pub scroll_dx: f32,
117    /// Vertical wheel-like delta in logical pixels.
118    pub scroll_dy: f32,
119}
120
121impl OpenXrPointerSample {
122    /// Creates a sample with zero scroll delta.
123    ///
124    /// # Examples
125    ///
126    /// ```
127    /// use ailloli_ui_openxr::{OpenXrPointerHit, OpenXrPointerSample};
128    ///
129    /// let sample = OpenXrPointerSample::new(1, OpenXrPointerHit::Miss, true);
130    /// assert!(sample.trigger_pressed);
131    /// assert_eq!(sample.scroll_dy, 0.0);
132    /// ```
133    pub fn new(source_id: u64, hit: OpenXrPointerHit, trigger_pressed: bool) -> Self {
134        Self {
135            source_id,
136            hit,
137            trigger_pressed,
138            scroll_dx: 0.0,
139            scroll_dy: 0.0,
140        }
141    }
142
143    /// Replaces both scroll deltas, in logical pixels.
144    ///
145    /// Values are preserved verbatim, including zero and negative deltas.
146    ///
147    /// # Examples
148    ///
149    /// ```
150    /// use ailloli_ui_openxr::{OpenXrPointerHit, OpenXrPointerSample};
151    ///
152    /// let sample = OpenXrPointerSample::new(1, OpenXrPointerHit::Miss, false)
153    ///     .with_scroll(-2.0, 4.0);
154    /// assert_eq!((sample.scroll_dx, sample.scroll_dy), (-2.0, 4.0));
155    /// ```
156    pub fn with_scroll(mut self, scroll_dx: f32, scroll_dy: f32) -> Self {
157        self.scroll_dx = scroll_dx;
158        self.scroll_dy = scroll_dy;
159        self
160    }
161}
162
163/// Host-facing abstraction for streaming XR input samples.
164///
165/// # Examples
166///
167/// ```
168/// use ailloli_ui_openxr::{OpenXrPointerHit, OpenXrPointerSample, OpenXrPointerSource};
169///
170/// let source = OpenXrPointerSample::new(42, OpenXrPointerHit::Miss, false);
171/// assert_eq!(source.sample().source_id, 42);
172/// ```
173pub trait OpenXrPointerSource {
174    /// Captures the source's current state for one host polling iteration.
175    ///
176    /// # Examples
177    ///
178    /// ```
179    /// use ailloli_ui_openxr::{OpenXrPointerHit, OpenXrPointerSample, OpenXrPointerSource};
180    ///
181    /// let source = OpenXrPointerSample::new(9, OpenXrPointerHit::Miss, false);
182    /// let sample: OpenXrPointerSample = source.sample();
183    /// assert_eq!(sample.source_id, 9);
184    /// ```
185    fn sample(&self) -> OpenXrPointerSample;
186}
187
188impl OpenXrPointerSource for OpenXrPointerSample {
189    fn sample(&self) -> OpenXrPointerSample {
190        *self
191    }
192}
193
194/// Input frame emitted by host bridge for one poll iteration.
195///
196/// An empty vector is a valid frame and emits no events. Omitted source IDs do
197/// not implicitly clear mapper state; call [`OpenXrInputMapper::clear_source`]
198/// when a source is permanently removed.
199///
200/// # Examples
201///
202/// ```
203/// use ailloli_ui_openxr::OpenXrPointerFrame;
204///
205/// let frame = OpenXrPointerFrame::new(Vec::new());
206/// assert!(frame.samples.is_empty());
207/// ```
208#[derive(Debug, Default, Clone)]
209pub struct OpenXrPointerFrame {
210    /// Samples to process in order for this polling iteration.
211    pub samples: Vec<OpenXrPointerSample>,
212}
213
214impl OpenXrPointerFrame {
215    /// Wraps the ordered set of samples for one frame.
216    ///
217    /// # Examples
218    ///
219    /// ```
220    /// use ailloli_ui_openxr::{OpenXrPointerFrame, OpenXrPointerHit, OpenXrPointerSample};
221    ///
222    /// let frame = OpenXrPointerFrame::new(vec![OpenXrPointerSample::new(
223    ///     2,
224    ///     OpenXrPointerHit::Miss,
225    ///     false,
226    /// )]);
227    /// assert_eq!(frame.samples.len(), 1);
228    /// ```
229    pub fn new(samples: Vec<OpenXrPointerSample>) -> Self {
230        Self { samples }
231    }
232}
233
234#[derive(Debug, Default)]
235/// Last routed logical point and primary-button state for one stable source ID.
236struct SourceState {
237    /// Last valid logical UI position emitted by this source.
238    last_pos: Option<Point>,
239    /// Whether the source's primary activation was pressed last frame.
240    pressed: bool,
241}
242
243/// Stateful mapper converting XR frame samples into `ailloli_ui_core::Event`.
244///
245/// State is keyed by `source_id` and records the last hit position and button
246/// state so moves, presses, and releases are emitted only on transitions.
247///
248/// # Examples
249///
250/// ```
251/// use ailloli_ui_openxr::OpenXrInputMapper;
252///
253/// let mapper = OpenXrInputMapper::new();
254/// assert!(format!("{mapper:?}").contains("source_state"));
255/// ```
256#[derive(Debug, Default)]
257pub struct OpenXrInputMapper {
258    /// Per-controller or hand state keyed by stable OpenXR source identity.
259    source_state: HashMap<u64, SourceState>,
260}
261
262impl OpenXrInputMapper {
263    /// Creates a mapper with no remembered sources.
264    ///
265    /// # Examples
266    ///
267    /// ```
268    /// use ailloli_ui_openxr::OpenXrInputMapper;
269    ///
270    /// let _: OpenXrInputMapper = OpenXrInputMapper::new();
271    /// ```
272    pub fn new() -> Self {
273        Self::default()
274    }
275
276    /// Forgets all hover and pressed state without emitting release events.
277    ///
278    /// # Examples
279    ///
280    /// ```
281    /// use ailloli_ui_openxr::OpenXrInputMapper;
282    ///
283    /// let mut mapper = OpenXrInputMapper::new();
284    /// mapper.clear();
285    /// ```
286    pub fn clear(&mut self) {
287        self.source_state.clear();
288    }
289
290    /// Forgets one stable source ID without emitting a release event.
291    ///
292    /// Missing IDs are a no-op.
293    ///
294    /// # Examples
295    ///
296    /// ```
297    /// use ailloli_ui_openxr::OpenXrInputMapper;
298    ///
299    /// let mut mapper = OpenXrInputMapper::new();
300    /// mapper.clear_source(17);
301    /// ```
302    pub fn clear_source(&mut self, source_id: u64) {
303        self.source_state.remove(&source_id);
304    }
305
306    /// Builds UI events from a complete pointer frame while tracking transitions.
307    ///
308    /// Samples are mapped in vector order. A hit may emit move, button, then
309    /// wheel events; a miss emits only a required release at the last hit point.
310    ///
311    /// # Examples
312    ///
313    /// ```
314    /// use ailloli_ui_core::event::Modifiers;
315    /// use ailloli_ui_openxr::{OpenXrInputMapper, OpenXrPointerFrame};
316    ///
317    /// let events = OpenXrInputMapper::new()
318    ///     .map_frame_to_events(&OpenXrPointerFrame::new(Vec::new()), Modifiers::default());
319    /// assert!(events.is_empty());
320    /// ```
321    pub fn map_frame_to_events(
322        &mut self,
323        frame: &OpenXrPointerFrame,
324        modifiers: Modifiers,
325    ) -> Vec<Event> {
326        let mut out = Vec::new();
327        for sample in &frame.samples {
328            out.extend(self.map_sample_to_events(*sample, modifiers));
329        }
330        out
331    }
332
333    /// Emits ordered move/button/wheel transitions for one sample and updates state.
334    fn map_sample_to_events(
335        &mut self,
336        sample: OpenXrPointerSample,
337        modifiers: Modifiers,
338    ) -> Vec<Event> {
339        let state = self.source_state.entry(sample.source_id).or_default();
340        let pos = sample.hit.point();
341        let mut events = Vec::new();
342
343        if let Some(pos) = pos {
344            if state.last_pos != Some(pos) {
345                events.push(Event::Pointer(PointerEvent::Moved { pos, modifiers }));
346            }
347
348            if state.pressed != sample.trigger_pressed {
349                events.push(Event::Pointer(PointerEvent::Button {
350                    pos,
351                    button: MouseButton::Left,
352                    pressed: sample.trigger_pressed,
353                    modifiers,
354                }));
355            }
356
357            if sample.scroll_dx != 0.0 || sample.scroll_dy != 0.0 {
358                events.push(Event::Pointer(PointerEvent::Wheel {
359                    pos,
360                    delta: WheelDelta::PixelDelta {
361                        x: sample.scroll_dx,
362                        y: sample.scroll_dy,
363                    },
364                    modifiers,
365                    precise: true,
366                }));
367            }
368
369            state.last_pos = Some(pos);
370            state.pressed = sample.trigger_pressed;
371            return events;
372        }
373
374        if state.pressed && !sample.trigger_pressed {
375            if let Some(pos) = state.last_pos {
376                events.push(Event::Pointer(PointerEvent::Button {
377                    pos,
378                    button: MouseButton::Left,
379                    pressed: false,
380                    modifiers,
381                }));
382                state.pressed = false;
383            }
384        } else {
385            state.pressed = sample.trigger_pressed;
386        }
387        events
388    }
389}
390
391/// Convenience helper mapping one frame with a shared mapper instance.
392///
393/// Source order determines event order. Mapper state is retained across calls.
394///
395/// # Examples
396///
397/// ```
398/// use ailloli_ui_core::event::Modifiers;
399/// use ailloli_ui_openxr::{map_ray_to_openxr_input_events, OpenXrInputMapper, OpenXrPointerHit, OpenXrPointerSample};
400///
401/// let sources = [OpenXrPointerSample::new(1, OpenXrPointerHit::Miss, false)];
402/// let events = map_ray_to_openxr_input_events(
403///     &mut OpenXrInputMapper::new(),
404///     &sources,
405///     Modifiers::default(),
406/// );
407/// assert!(events.is_empty());
408/// ```
409pub fn map_ray_to_openxr_input_events<A: OpenXrPointerSource>(
410    mapper: &mut OpenXrInputMapper,
411    sources: &[A],
412    modifiers: Modifiers,
413) -> Vec<Event> {
414    let frame = OpenXrPointerFrame {
415        samples: sources.iter().map(|source| source.sample()).collect(),
416    };
417    mapper.map_frame_to_events(&frame, modifiers)
418}
419
420/// Maps an already-collected frame through a shared stateful mapper.
421///
422/// # Examples
423///
424/// ```
425/// use ailloli_ui_core::event::Modifiers;
426/// use ailloli_ui_openxr::{map_samples_to_openxr_input_events, OpenXrInputMapper, OpenXrPointerFrame};
427///
428/// let events = map_samples_to_openxr_input_events(
429///     &mut OpenXrInputMapper::new(),
430///     &OpenXrPointerFrame::new(Vec::new()),
431///     Modifiers::default(),
432/// );
433/// assert!(events.is_empty());
434/// ```
435pub fn map_samples_to_openxr_input_events(
436    mapper: &mut OpenXrInputMapper,
437    frame: &OpenXrPointerFrame,
438    modifiers: Modifiers,
439) -> Vec<Event> {
440    mapper.map_frame_to_events(frame, modifiers)
441}
442
443#[cfg(test)]
444/// Verifies transition ordering, miss handling, and release synthesis.
445mod tests {
446    use super::*;
447    use ailloli_ui_core::event::Event;
448
449    /// Returns an all-false modifier fixture for pointer mapping tests.
450    fn mods() -> Modifiers {
451        Modifiers {
452            ctrl: false,
453            shift: false,
454            alt: false,
455            meta: false,
456        }
457    }
458
459    #[test]
460    fn emits_press_and_release() {
461        let mut mapper = OpenXrInputMapper::new();
462        let down = OpenXrPointerSample::new(
463            1,
464            OpenXrPointerHit::Hit(PointHit::new(Point::new(10.0, 20.0), Some(0.5))),
465            true,
466        );
467        let up = OpenXrPointerSample::new(
468            1,
469            OpenXrPointerHit::Hit(PointHit::new(Point::new(10.0, 20.0), Some(0.5))),
470            false,
471        );
472
473        let down_events = map_samples_to_openxr_input_events(
474            &mut mapper,
475            &OpenXrPointerFrame::new(vec![down]),
476            mods(),
477        );
478        assert_eq!(down_events.len(), 2);
479        assert!(matches!(
480            down_events[0],
481            Event::Pointer(PointerEvent::Moved { .. })
482        ));
483        assert!(matches!(
484            down_events[1],
485            Event::Pointer(PointerEvent::Button { pressed: true, .. })
486        ));
487
488        let up_events = map_samples_to_openxr_input_events(
489            &mut mapper,
490            &OpenXrPointerFrame::new(vec![up]),
491            mods(),
492        );
493        assert_eq!(up_events.len(), 1);
494        assert!(matches!(
495            up_events[0],
496            Event::Pointer(PointerEvent::Button { pressed: false, .. })
497        ));
498    }
499
500    #[test]
501    fn scroll_sample_emits_precise_pixel_axes_and_modifiers() {
502        let mut mapper = OpenXrInputMapper::new();
503        let sample = OpenXrPointerSample::new(
504            17,
505            OpenXrPointerHit::Hit(PointHit::new(Point::new(3.0, 4.0), None)),
506            false,
507        )
508        .with_scroll(-2.5, 7.0);
509        let modifiers = Modifiers {
510            shift: true,
511            ..Modifiers::default()
512        };
513        let events = map_samples_to_openxr_input_events(
514            &mut mapper,
515            &OpenXrPointerFrame::new(vec![sample]),
516            modifiers,
517        );
518
519        assert!(matches!(
520            events.as_slice(),
521            [
522                Event::Pointer(PointerEvent::Moved { .. }),
523                Event::Pointer(PointerEvent::Wheel {
524                    delta: WheelDelta::PixelDelta { x, y },
525                    modifiers: event_modifiers,
526                    precise: true,
527                    ..
528                })
529            ] if *x == -2.5 && *y == 7.0 && *event_modifiers == modifiers
530        ));
531    }
532
533    #[test]
534    fn keeps_hover_when_out_of_ui() {
535        let mut mapper = OpenXrInputMapper::new();
536        let frame1 = OpenXrPointerFrame::new(vec![OpenXrPointerSample::new(
537            7,
538            OpenXrPointerHit::Hit(PointHit::new(Point::new(2.0, 3.0), None)),
539            false,
540        )]);
541        let frame2 = OpenXrPointerFrame::new(vec![OpenXrPointerSample::new(
542            7,
543            OpenXrPointerHit::Miss,
544            false,
545        )]);
546        let frame3 = OpenXrPointerFrame::new(vec![OpenXrPointerSample {
547            source_id: 7,
548            hit: OpenXrPointerHit::Miss,
549            trigger_pressed: false,
550            scroll_dx: 0.0,
551            scroll_dy: 1.0,
552        }]);
553
554        assert_eq!(
555            map_samples_to_openxr_input_events(&mut mapper, &frame1, mods()).len(),
556            1
557        );
558        assert_eq!(
559            map_samples_to_openxr_input_events(&mut mapper, &frame2, mods()).len(),
560            0
561        );
562        assert_eq!(
563            map_samples_to_openxr_input_events(&mut mapper, &frame3, mods()).len(),
564            0
565        );
566    }
567
568    #[test]
569    fn releases_press_on_leave_ui() {
570        let mut mapper = OpenXrInputMapper::new();
571        let press = OpenXrPointerSample::new(
572            8,
573            OpenXrPointerHit::Hit(PointHit::new(Point::new(4.0, 5.0), None)),
574            true,
575        );
576        let leave = OpenXrPointerSample::new(8, OpenXrPointerHit::Miss, false);
577
578        assert_eq!(
579            map_samples_to_openxr_input_events(
580                &mut mapper,
581                &OpenXrPointerFrame::new(vec![press]),
582                mods()
583            )
584            .len(),
585            2
586        );
587        assert_eq!(
588            map_samples_to_openxr_input_events(
589                &mut mapper,
590                &OpenXrPointerFrame::new(vec![leave]),
591                mods()
592            )
593            .len(),
594            1
595        );
596    }
597}