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}