ui_events_web/
pointer.rs

1// Copyright 2025 the UI Events Authors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Support routines for converting pointer data from [`web_sys`].
5
6use alloc::vec;
7use alloc::vec::Vec;
8
9use dpi::{PhysicalPosition, PhysicalSize};
10use js_sys::{Array, Function, Reflect};
11use ui_events::ScrollDelta;
12use ui_events::keyboard::Modifiers;
13use ui_events::pointer::{
14    PointerButton, PointerButtonEvent, PointerButtons, PointerEvent, PointerId, PointerInfo,
15    PointerOrientation, PointerState, PointerType, PointerUpdate,
16};
17use web_sys::wasm_bindgen::{JsCast, JsValue};
18use web_sys::{
19    Element, Event, MouseEvent, PointerEvent as WebPointerEvent, Touch, TouchEvent, TouchList,
20    WheelEvent,
21};
22
23#[inline]
24#[expect(
25    clippy::cast_possible_truncation,
26    reason = "DOM timestamp is f64 ms; convert to integer ns intentionally"
27)]
28fn ms_to_ns_u64(ms: f64) -> u64 {
29    (ms * 1_000_000.0) as u64
30}
31
32#[inline]
33#[expect(
34    clippy::cast_possible_truncation,
35    reason = "DOM wheel line/page deltas are f64; ui-events stores f32"
36)]
37fn f64_to_f32_delta(v: f64) -> f32 {
38    v as f32
39}
40
41/// Try to make a [`PointerButton`] from a [`web_sys::MouseEvent::button`].
42///
43/// Values less than 0 or greater than 31 will not be mapped.
44///
45/// This corresponds to §5.1.1.2 of the Pointer Events Level 2
46/// specification.
47pub fn try_from_web_button(b: i16) -> Option<PointerButton> {
48    Some(match b {
49        0 => PointerButton::Primary,
50        // https://www.w3.org/TR/uievents/#dom-mouseevent-button
51        // 1 = auxiliary (middle), 2 = secondary (right)
52        1 => PointerButton::Auxiliary,
53        2 => PointerButton::Secondary,
54        3 => PointerButton::X1,
55        4 => PointerButton::X2,
56        5 => PointerButton::PenEraser,
57        6 => PointerButton::B7,
58        7 => PointerButton::B8,
59        8 => PointerButton::B9,
60        9 => PointerButton::B10,
61        10 => PointerButton::B11,
62        11 => PointerButton::B12,
63        12 => PointerButton::B13,
64        13 => PointerButton::B14,
65        14 => PointerButton::B15,
66        15 => PointerButton::B16,
67        16 => PointerButton::B17,
68        17 => PointerButton::B18,
69        18 => PointerButton::B19,
70        19 => PointerButton::B20,
71        20 => PointerButton::B21,
72        21 => PointerButton::B22,
73        22 => PointerButton::B23,
74        23 => PointerButton::B24,
75        24 => PointerButton::B25,
76        25 => PointerButton::B26,
77        26 => PointerButton::B27,
78        27 => PointerButton::B28,
79        28 => PointerButton::B29,
80        29 => PointerButton::B30,
81        30 => PointerButton::B31,
82        31 => PointerButton::B32,
83        _ => {
84            return None;
85        }
86    })
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use ui_events::pointer::PointerButton;
93
94    #[test]
95    fn web_mouse_button_mapping_matches_dom_spec() {
96        assert_eq!(try_from_web_button(0), Some(PointerButton::Primary));
97        assert_eq!(try_from_web_button(1), Some(PointerButton::Auxiliary));
98        assert_eq!(try_from_web_button(2), Some(PointerButton::Secondary));
99        assert_eq!(try_from_web_button(3), Some(PointerButton::X1));
100        assert_eq!(try_from_web_button(4), Some(PointerButton::X2));
101        assert_eq!(try_from_web_button(-1), None);
102        assert_eq!(try_from_web_button(32), None);
103    }
104}
105
106/// Convert a DOM `MouseEvent.buttons()` bitfield into [`PointerButtons`].
107pub fn from_web_buttons_mask(mask: u16) -> PointerButtons {
108    // Compute in u32 to avoid shifting a 16-bit value by >= 16 (which panics in debug on wasm).
109    let mask32 = mask as u32;
110    let mut out = PointerButtons::default();
111    for (i, btn) in NONZERO_VARIANTS.iter().enumerate() {
112        if (mask32 & (1_u32 << i)) != 0 {
113            out.insert(*btn);
114        }
115    }
116    out
117}
118
119const NONZERO_VARIANTS: [PointerButton; 32] = [
120    PointerButton::Primary,
121    PointerButton::Secondary,
122    PointerButton::Auxiliary,
123    PointerButton::X1,
124    PointerButton::X2,
125    PointerButton::PenEraser,
126    PointerButton::B7,
127    PointerButton::B8,
128    PointerButton::B9,
129    PointerButton::B10,
130    PointerButton::B11,
131    PointerButton::B12,
132    PointerButton::B13,
133    PointerButton::B14,
134    PointerButton::B15,
135    PointerButton::B16,
136    PointerButton::B17,
137    PointerButton::B18,
138    PointerButton::B19,
139    PointerButton::B20,
140    PointerButton::B21,
141    PointerButton::B22,
142    PointerButton::B23,
143    PointerButton::B24,
144    PointerButton::B25,
145    PointerButton::B26,
146    PointerButton::B27,
147    PointerButton::B28,
148    PointerButton::B29,
149    PointerButton::B30,
150    PointerButton::B31,
151    PointerButton::B32,
152];
153
154/// Build a basic [`PointerState`] from a [`MouseEvent`].
155///
156/// Prefer [`state_from_pointer_event`] when handling W3C Pointer Events,
157/// as it includes richer data (pressure, width/height, etc.).
158///
159/// - Coordinates use `clientX/Y` scaled by `scale_factor` to approximate physical pixels.
160/// - Pressure is 0.5 when any button is down, else 0.0.
161pub fn state_from_mouse_event(e: &MouseEvent, scale_factor: f64) -> PointerState {
162    let css_x = e.client_x() as f64;
163    let css_y = e.client_y() as f64;
164    let buttons = from_web_buttons_mask(e.buttons());
165    let pressure = if buttons.is_empty() { 0.0 } else { 0.5 };
166    let time_ns = ms_to_ns_u64(e.time_stamp());
167    PointerState {
168        time: time_ns, // ms -> ns
169        position: PhysicalPosition {
170            x: css_x * scale_factor,
171            y: css_y * scale_factor,
172        },
173        buttons,
174        modifiers: modifiers_from_mouse(e),
175        count: e.detail().clamp(0, 255) as u8,
176        contact_geometry: PhysicalSize {
177            width: 1.0,
178            height: 1.0,
179        },
180        orientation: Default::default(),
181        pressure,
182        tangential_pressure: 0.0,
183        scale_factor,
184    }
185}
186
187fn modifiers_from_mouse(e: &MouseEvent) -> Modifiers {
188    let mut m = Modifiers::default();
189    if e.ctrl_key() {
190        m.insert(Modifiers::CONTROL);
191    }
192    if e.alt_key() {
193        m.insert(Modifiers::ALT);
194    }
195    if e.shift_key() {
196        m.insert(Modifiers::SHIFT);
197    }
198    if e.meta_key() {
199        m.insert(Modifiers::META);
200    }
201    m
202}
203
204fn pointer_info_mouse() -> PointerInfo {
205    PointerInfo {
206        pointer_id: Some(PointerId::PRIMARY),
207        persistent_device_id: None,
208        pointer_type: PointerType::Mouse,
209    }
210}
211
212/// Build a `Down` from a DOM `mousedown`/`pointerdown` represented as [`MouseEvent`].
213///
214/// Prefer [`down_from_pointer_event`] when handling W3C Pointer Events.
215pub fn down_from_mouse_event(e: &MouseEvent, scale_factor: f64) -> PointerEvent {
216    PointerEvent::Down(PointerButtonEvent {
217        button: try_from_web_button(e.button()),
218        pointer: pointer_info_mouse(),
219        state: state_from_mouse_event(e, scale_factor),
220    })
221}
222
223/// Build an `Up` from a DOM `mouseup`/`pointerup` represented as [`MouseEvent`].
224///
225/// Prefer [`up_from_pointer_event`] when handling W3C Pointer Events.
226pub fn up_from_mouse_event(e: &MouseEvent, scale_factor: f64) -> PointerEvent {
227    PointerEvent::Up(PointerButtonEvent {
228        button: try_from_web_button(e.button()),
229        pointer: pointer_info_mouse(),
230        state: state_from_mouse_event(e, scale_factor),
231    })
232}
233
234/// Build a `Move` from a DOM `mousemove`/`pointermove` represented as [`MouseEvent`].
235///
236/// Prefer [`move_from_pointer_event`] when handling W3C Pointer Events.
237pub fn move_from_mouse_event(e: &MouseEvent, scale_factor: f64) -> PointerEvent {
238    PointerEvent::Move(PointerUpdate {
239        pointer: pointer_info_mouse(),
240        current: state_from_mouse_event(e, scale_factor),
241        coalesced: Vec::new(),
242        predicted: Vec::new(),
243    })
244}
245
246/// Build an `Enter` from a DOM `mouseenter`/`pointerenter`.
247///
248/// Prefer [`enter_from_pointer_event`] when handling W3C Pointer Events.
249pub fn enter_from_mouse_event(_e: &MouseEvent) -> PointerEvent {
250    PointerEvent::Enter(pointer_info_mouse())
251}
252
253/// Build a `Leave` from a DOM `mouseleave`/`pointerleave`.
254///
255/// Prefer [`leave_from_pointer_event`] when handling W3C Pointer Events.
256pub fn leave_from_mouse_event(_e: &MouseEvent) -> PointerEvent {
257    PointerEvent::Leave(pointer_info_mouse())
258}
259
260/// Build a `Scroll` from a DOM `wheel` event.
261///
262/// `scale_factor` controls conversion of CSS pixel deltas to physical pixels.
263pub fn scroll_from_wheel_event(e: &WheelEvent, scale_factor: f64) -> PointerEvent {
264    let delta = match e.delta_mode() {
265        WheelEvent::DOM_DELTA_PIXEL => ScrollDelta::PixelDelta(PhysicalPosition {
266            x: e.delta_x() * scale_factor,
267            y: e.delta_y() * scale_factor,
268        }),
269        WheelEvent::DOM_DELTA_LINE => {
270            ScrollDelta::LineDelta(f64_to_f32_delta(e.delta_x()), f64_to_f32_delta(e.delta_y()))
271        }
272        WheelEvent::DOM_DELTA_PAGE => {
273            ScrollDelta::PageDelta(f64_to_f32_delta(e.delta_x()), f64_to_f32_delta(e.delta_y()))
274        }
275        _ => ScrollDelta::PixelDelta(PhysicalPosition { x: 0.0, y: 0.0 }),
276    };
277
278    let me: &MouseEvent = e;
279    PointerEvent::Scroll(ui_events::pointer::PointerScrollEvent {
280        pointer: pointer_info_mouse(),
281        delta,
282        state: state_from_mouse_event(me, scale_factor),
283    })
284}
285
286// PointerEvent (Web) conversions
287
288fn pointer_type_from_str(s: &str) -> PointerType {
289    match s {
290        "mouse" => PointerType::Mouse,
291        "pen" => PointerType::Pen,
292        "touch" => PointerType::Touch,
293        _ => PointerType::Unknown,
294    }
295}
296
297fn pointer_info_from_web_pointer(e: &WebPointerEvent) -> PointerInfo {
298    let id = if e.is_primary() {
299        Some(PointerId::PRIMARY)
300    } else {
301        let raw = e.pointer_id() as u64;
302        // Shift non-primary ids by +1 to avoid colliding with PRIMARY (1).
303        PointerId::new(raw.saturating_add(1))
304    };
305    PointerInfo {
306        pointer_id: id,
307        persistent_device_id: None,
308        pointer_type: pointer_type_from_str(&e.pointer_type()),
309    }
310}
311
312fn modifiers_from_pointer(e: &WebPointerEvent) -> Modifiers {
313    let mut m = Modifiers::default();
314    if e.ctrl_key() {
315        m.insert(Modifiers::CONTROL);
316    }
317    if e.alt_key() {
318        m.insert(Modifiers::ALT);
319    }
320    if e.shift_key() {
321        m.insert(Modifiers::SHIFT);
322    }
323    if e.meta_key() {
324        m.insert(Modifiers::META);
325    }
326    m
327}
328
329fn orientation_from_pointer_event(e: &WebPointerEvent) -> PointerOrientation {
330    // Prefer Pointer Events Level 3 altitude/azimuth when present (radians).
331    let obj = e.as_ref();
332    if let (Ok(alt), Ok(azi)) = (
333        Reflect::get(obj, &JsValue::from_str("altitudeAngle")),
334        Reflect::get(obj, &JsValue::from_str("azimuthAngle")),
335    ) {
336        if let (Some(alt), Some(azi)) = (alt.as_f64(), azi.as_f64()) {
337            #[expect(
338                clippy::cast_possible_truncation,
339                reason = "DOM provides f64 radians; ui-events stores orientation as f32"
340            )]
341            return PointerOrientation {
342                altitude: alt as f32,
343                azimuth: azi as f32,
344            };
345        }
346    }
347
348    // Fall back to Pointer Events tiltX/tiltY (degrees).
349    // tiltX/tiltY are in [-90, 90]. Avoid tan() singularities at 90 degrees.
350    let tilt_x = (e.tilt_x() as f32).clamp(-89.9, 89.9);
351    let tilt_y = (e.tilt_y() as f32).clamp(-89.9, 89.9);
352    pointer_orientation_from_tilt_degrees(tilt_x, tilt_y)
353}
354
355fn pointer_orientation_from_tilt_degrees(tilt_x_deg: f32, tilt_y_deg: f32) -> PointerOrientation {
356    let tx = tilt_x_deg.to_radians();
357    let ty = tilt_y_deg.to_radians();
358    let x = tx.tan();
359    let y = ty.tan();
360
361    // Model the pen axis as the normalized vector (x, y, 1), where x/z = tan(tiltX),
362    // y/z = tan(tiltY). When perpendicular: (0,0,1).
363    let inv_norm = 1.0 / (x.mul_add(x, y * y) + 1.0).sqrt();
364    let z = inv_norm;
365
366    let altitude = z.asin();
367    let azimuth = if x == 0.0 && y == 0.0 {
368        core::f32::consts::FRAC_PI_2
369    } else {
370        y.atan2(x)
371    };
372
373    PointerOrientation { altitude, azimuth }
374}
375
376/// Build a [`PointerState`] from a DOM [`web_sys::PointerEvent`].
377///
378/// - Coordinates use `clientX/Y` scaled by `scale_factor` to approximate
379///   physical pixels.
380/// - Uses the event's reported `pressure`, `tangentialPressure`, `width/height`, and
381///   stylus orientation where available (preferring `altitudeAngle`/`azimuthAngle`,
382///   otherwise falling back to `tiltX`/`tiltY`).
383/// - Pointer Events `twist` is not currently mapped (there is no corresponding field in
384///   `ui-events`).
385pub fn state_from_pointer_event(e: &WebPointerEvent, scale_factor: f64) -> PointerState {
386    let css_x = e.client_x() as f64;
387    let css_y = e.client_y() as f64;
388    let buttons = from_web_buttons_mask(e.buttons());
389    let pressure = e.pressure();
390    let tangential_pressure = e.tangential_pressure();
391    let width = e.width() as f64 * scale_factor;
392    let height = e.height() as f64 * scale_factor;
393    let time_ns = ms_to_ns_u64(e.time_stamp());
394    PointerState {
395        time: time_ns,
396        position: PhysicalPosition {
397            x: css_x * scale_factor,
398            y: css_y * scale_factor,
399        },
400        buttons,
401        modifiers: modifiers_from_pointer(e),
402        count: e.detail().clamp(0, 255) as u8,
403        contact_geometry: PhysicalSize { width, height },
404        orientation: orientation_from_pointer_event(e),
405        pressure,
406        tangential_pressure,
407        scale_factor,
408    }
409}
410
411/// Build a [`PointerEvent::Down`] from a DOM `pointerdown`.
412pub fn down_from_pointer_event(e: &WebPointerEvent, scale_factor: f64) -> PointerEvent {
413    PointerEvent::Down(PointerButtonEvent {
414        button: try_from_web_button(e.button()),
415        pointer: pointer_info_from_web_pointer(e),
416        state: state_from_pointer_event(e, scale_factor),
417    })
418}
419
420/// Build an [`PointerEvent::Up`] from a DOM `pointerup`.
421pub fn up_from_pointer_event(e: &WebPointerEvent, scale_factor: f64) -> PointerEvent {
422    PointerEvent::Up(PointerButtonEvent {
423        button: try_from_web_button(e.button()),
424        pointer: pointer_info_from_web_pointer(e),
425        state: state_from_pointer_event(e, scale_factor),
426    })
427}
428
429/// Controls how pointer events are converted.
430#[derive(Clone, Copy, Debug)]
431pub struct Options {
432    /// Scale factor to convert CSS pixels to physical pixels.
433    pub scale_factor: f64,
434    /// Whether to collect coalesced move samples.
435    pub collect_coalesced: bool,
436    /// Whether to collect predicted move samples.
437    pub collect_predicted: bool,
438}
439
440impl Default for Options {
441    fn default() -> Self {
442        // Defaults avoid allocations on hot paths; enable explicitly when desired.
443        Self {
444            scale_factor: 1.0,
445            collect_coalesced: false,
446            collect_predicted: false,
447        }
448    }
449}
450
451impl Options {
452    /// Set the scale factor (builder style).
453    pub fn with_scale(mut self, scale: f64) -> Self {
454        self.scale_factor = scale;
455        self
456    }
457    /// Set whether to collect coalesced samples.
458    pub fn with_coalesced(mut self, enabled: bool) -> Self {
459        self.collect_coalesced = enabled;
460        self
461    }
462    /// Set whether to collect predicted samples.
463    pub fn with_predicted(mut self, enabled: bool) -> Self {
464        self.collect_predicted = enabled;
465        self
466    }
467}
468
469/// Build a `Move` from a DOM `pointermove`, with conversion options.
470pub fn move_from_pointer_event(e: &WebPointerEvent, opts: &Options) -> PointerEvent {
471    let pointer = pointer_info_from_web_pointer(e);
472    let current = state_from_pointer_event(e, opts.scale_factor);
473
474    let coalesced_states = if opts.collect_coalesced {
475        get_coalesced_events_safe(e, opts.scale_factor)
476    } else {
477        Vec::new()
478    };
479
480    let predicted_states = if opts.collect_predicted {
481        get_predicted_events_safe(e, opts.scale_factor)
482    } else {
483        Vec::new()
484    };
485
486    PointerEvent::Move(PointerUpdate {
487        pointer,
488        current,
489        coalesced: coalesced_states,
490        predicted: predicted_states,
491    })
492}
493
494fn collect_states_from_array(arr: &Array, scale_factor: f64) -> Vec<PointerState> {
495    let mut out = Vec::new();
496    let len = arr.length();
497    for i in 0..len {
498        let v = arr.get(i);
499        if let Ok(pe) = v.dyn_into::<WebPointerEvent>() {
500            out.push(state_from_pointer_event(&pe, scale_factor));
501        }
502    }
503    out
504}
505
506fn get_coalesced_events_safe(e: &WebPointerEvent, scale_factor: f64) -> Vec<PointerState> {
507    let obj = e.as_ref();
508    let Ok(v) = Reflect::get(
509        obj,
510        &web_sys::wasm_bindgen::JsValue::from_str("getCoalescedEvents"),
511    ) else {
512        return Vec::new();
513    };
514    if !v.is_function() {
515        return Vec::new();
516    }
517    let f: Function = v.unchecked_into();
518    let Ok(jsarr) = f.call0(obj) else {
519        return Vec::new();
520    };
521    let Ok(arr) = jsarr.dyn_into::<Array>() else {
522        return Vec::new();
523    };
524    collect_states_from_array(&arr, scale_factor)
525}
526
527fn get_predicted_events_safe(e: &WebPointerEvent, scale_factor: f64) -> Vec<PointerState> {
528    let obj = e.as_ref();
529    let Ok(v) = Reflect::get(
530        obj,
531        &web_sys::wasm_bindgen::JsValue::from_str("getPredictedEvents"),
532    ) else {
533        return Vec::new();
534    };
535    if !v.is_function() {
536        return Vec::new();
537    }
538    let f: Function = v.unchecked_into();
539    let Ok(jsarr) = f.call0(obj) else {
540        return Vec::new();
541    };
542    let Ok(arr) = jsarr.dyn_into::<Array>() else {
543        return Vec::new();
544    };
545    collect_states_from_array(&arr, scale_factor)
546}
547
548/// Build an [`PointerEvent::Enter`] from a DOM `pointerenter`.
549pub fn enter_from_pointer_event(e: &WebPointerEvent) -> PointerEvent {
550    PointerEvent::Enter(pointer_info_from_web_pointer(e))
551}
552
553/// Build a [`PointerEvent::Leave`] from a DOM `pointerleave`.
554pub fn leave_from_pointer_event(e: &WebPointerEvent) -> PointerEvent {
555    PointerEvent::Leave(pointer_info_from_web_pointer(e))
556}
557
558/// Build a [`PointerEvent::Cancel`] from a DOM `pointercancel`.
559pub fn cancel_from_pointer_event(e: &WebPointerEvent) -> PointerEvent {
560    PointerEvent::Cancel(pointer_info_from_web_pointer(e))
561}
562
563/// Convert a DOM `TouchEvent` into zero or more `ui-events` [`PointerEvent`]s.
564///
565/// Browser touch events can report multiple changed touches at once, so this returns a `Vec`.
566/// For `touchstart`, `touchmove`, and `touchend`, the returned events correspond to the
567/// event's `changedTouches` list.
568///
569/// For `touchcancel`, the returned events are [`PointerEvent::Cancel`], which do not include
570/// pointer state.
571pub fn pointer_events_from_touch_event(ev: &TouchEvent, opts: &Options) -> Vec<PointerEvent> {
572    let time_ns = ms_to_ns_u64(ev.time_stamp());
573    let modifiers = modifiers_from_touch(ev);
574
575    let touch_count = pointer_attach_count_from_active_touches(ev.touches().length());
576    let primary_identifier = min_touch_identifier_from_event(ev);
577
578    let type_ = ev.type_();
579    let changed = ev.changed_touches();
580
581    let mut out = Vec::new();
582    let len = changed.length();
583    for i in 0..len {
584        let Some(touch) = changed.item(i) else {
585            continue;
586        };
587        let pointer = pointer_info_from_touch(&touch, primary_identifier);
588        match type_.as_str() {
589            "touchstart" => out.push(PointerEvent::Down(PointerButtonEvent {
590                button: None,
591                pointer,
592                state: state_from_touch(&touch, time_ns, modifiers, touch_count, opts.scale_factor),
593            })),
594            "touchmove" => out.push(PointerEvent::Move(PointerUpdate {
595                pointer,
596                current: state_from_touch(
597                    &touch,
598                    time_ns,
599                    modifiers,
600                    touch_count,
601                    opts.scale_factor,
602                ),
603                coalesced: Vec::new(),
604                predicted: Vec::new(),
605            })),
606            "touchend" => out.push(PointerEvent::Up(PointerButtonEvent {
607                button: None,
608                pointer,
609                state: state_from_touch_end(
610                    &touch,
611                    time_ns,
612                    modifiers,
613                    touch_count,
614                    opts.scale_factor,
615                ),
616            })),
617            "touchcancel" => out.push(PointerEvent::Cancel(pointer)),
618            _ => {}
619        }
620    }
621    out
622}
623
624fn modifiers_from_touch(e: &TouchEvent) -> Modifiers {
625    let mut m = Modifiers::default();
626    if e.ctrl_key() {
627        m.insert(Modifiers::CONTROL);
628    }
629    if e.alt_key() {
630        m.insert(Modifiers::ALT);
631    }
632    if e.shift_key() {
633        m.insert(Modifiers::SHIFT);
634    }
635    if e.meta_key() {
636        m.insert(Modifiers::META);
637    }
638    m
639}
640
641fn min_touch_identifier_from_event(ev: &TouchEvent) -> Option<u64> {
642    let mut min = min_touch_identifier(&ev.touches())?;
643    if let Some(changed_min) = min_touch_identifier(&ev.changed_touches()) {
644        min = min.min(changed_min);
645    }
646    Some(min)
647}
648
649fn min_touch_identifier(list: &TouchList) -> Option<u64> {
650    let mut min: Option<u64> = None;
651    let len = list.length();
652    for i in 0..len {
653        let Some(t) = list.item(i) else {
654            continue;
655        };
656        let id = touch_identifier_u64(&t)?;
657        min = Some(min.map_or(id, |m| m.min(id)));
658    }
659    min
660}
661
662fn touch_identifier_u64(touch: &Touch) -> Option<u64> {
663    let id = touch.identifier();
664    if id < 0 {
665        return None;
666    }
667    Some(id as u64)
668}
669
670fn pointer_id_from_touch_identifier(id: i32, primary_identifier: Option<u64>) -> Option<PointerId> {
671    if id < 0 {
672        return None;
673    }
674    let id_u64 = id as u64;
675    if primary_identifier.is_some_and(|p| p == id_u64) {
676        return Some(PointerId::PRIMARY);
677    }
678    PointerId::new(id_u64.saturating_add(2))
679}
680
681fn pointer_attach_count_from_active_touches(active_touches: u32) -> u8 {
682    active_touches.min(255) as u8
683}
684
685fn pointer_info_from_touch(touch: &Touch, primary_identifier: Option<u64>) -> PointerInfo {
686    PointerInfo {
687        pointer_id: pointer_id_from_touch_identifier(touch.identifier(), primary_identifier),
688        persistent_device_id: None,
689        pointer_type: PointerType::Touch,
690    }
691}
692
693fn state_from_touch(
694    touch: &Touch,
695    time_ns: u64,
696    modifiers: Modifiers,
697    touch_count: u8,
698    scale_factor: f64,
699) -> PointerState {
700    let css_x = touch.client_x() as f64;
701    let css_y = touch.client_y() as f64;
702
703    // Touch.radiusX/Y are radii in CSS pixels; `PointerState` stores a size.
704    let width_css = (touch.radius_x() as f64 * 2.0).max(1.0);
705    let height_css = (touch.radius_y() as f64 * 2.0).max(1.0);
706
707    let pressure = {
708        let f = touch.force();
709        if f > 0.0 { f } else { 0.5 }
710    };
711
712    PointerState {
713        time: time_ns,
714        position: PhysicalPosition {
715            x: css_x * scale_factor,
716            y: css_y * scale_factor,
717        },
718        buttons: PointerButtons::default(),
719        modifiers,
720        count: touch_count,
721        contact_geometry: PhysicalSize {
722            width: width_css * scale_factor,
723            height: height_css * scale_factor,
724        },
725        orientation: Default::default(),
726        pressure,
727        tangential_pressure: 0.0,
728        scale_factor,
729    }
730}
731
732fn state_from_touch_end(
733    touch: &Touch,
734    time_ns: u64,
735    modifiers: Modifiers,
736    touch_count: u8,
737    scale_factor: f64,
738) -> PointerState {
739    let mut s = state_from_touch(touch, time_ns, modifiers, touch_count, scale_factor);
740    s.pressure = 0.0;
741    s
742}
743
744/// Convert a DOM event (Touch/Mouse/Pointer/Wheel) into zero or more `ui-events`
745/// [`PointerEvent`]s with options to control conversion.
746pub fn pointer_events_from_dom_event(ev: &Event, opts: &Options) -> Vec<PointerEvent> {
747    if let Some(te) = ev.dyn_ref::<TouchEvent>() {
748        let out = pointer_events_from_touch_event(te, opts);
749        if !out.is_empty() {
750            return out;
751        }
752    }
753
754    if let Some(wheel) = ev.dyn_ref::<WheelEvent>() {
755        return vec![scroll_from_wheel_event(wheel, opts.scale_factor)];
756    }
757    if let Some(pe) = ev.dyn_ref::<WebPointerEvent>() {
758        let Some(out) = (match pe.type_().as_str() {
759            "pointerdown" => Some(down_from_pointer_event(pe, opts.scale_factor)),
760            "pointerup" => Some(up_from_pointer_event(pe, opts.scale_factor)),
761            "pointermove" => Some(move_from_pointer_event(pe, opts)),
762            "pointerenter" => Some(enter_from_pointer_event(pe)),
763            "pointerleave" => Some(leave_from_pointer_event(pe)),
764            "pointercancel" => Some(cancel_from_pointer_event(pe)),
765            _ => None,
766        }) else {
767            return Vec::new();
768        };
769        return vec![out];
770    }
771    if let Some(me) = ev.dyn_ref::<MouseEvent>() {
772        let Some(out) = (match me.type_().as_str() {
773            "mousedown" => Some(down_from_mouse_event(me, opts.scale_factor)),
774            "mouseup" => Some(up_from_mouse_event(me, opts.scale_factor)),
775            "mousemove" => Some(move_from_mouse_event(me, opts.scale_factor)),
776            "mouseenter" => Some(enter_from_mouse_event(me)),
777            "mouseleave" => Some(leave_from_mouse_event(me)),
778            _ => None,
779        }) else {
780            return Vec::new();
781        };
782        return vec![out];
783    }
784    Vec::new()
785}
786
787/// Convert a DOM event (Mouse/Pointer/Wheel) into a `ui-events` [`PointerEvent`]
788/// with options to control conversion.
789///
790/// For multi-touch events, this returns the primary pointer's event when possible,
791/// otherwise it returns an arbitrary changed touch.
792pub fn pointer_event_from_dom_event(ev: &Event, opts: &Options) -> Option<PointerEvent> {
793    let mut events = pointer_events_from_dom_event(ev, opts);
794    if events.is_empty() {
795        return None;
796    }
797    if let Some(primary_idx) = events.iter().position(PointerEvent::is_primary_pointer) {
798        return Some(events.swap_remove(primary_idx));
799    }
800    events.into_iter().next()
801}
802
803/// Set pointer capture on an element using the id from a `PointerEvent`.
804pub fn set_pointer_capture(
805    el: &Element,
806    e: &WebPointerEvent,
807) -> Result<(), web_sys::js_sys::JsString> {
808    Ok(el.set_pointer_capture(e.pointer_id())?)
809}
810
811/// Release pointer capture on an element using the id from a `PointerEvent`.
812pub fn release_pointer_capture(
813    el: &Element,
814    e: &WebPointerEvent,
815) -> Result<(), web_sys::js_sys::JsString> {
816    Ok(el.release_pointer_capture(e.pointer_id())?)
817}
818
819/// Query whether an element currently has capture for this pointer id.
820pub fn has_pointer_capture(el: &Element, e: &WebPointerEvent) -> bool {
821    el.has_pointer_capture(e.pointer_id())
822}
823
824#[cfg(test)]
825mod touch_tests {
826    use super::*;
827
828    #[test]
829    fn touch_identifier_to_pointer_id_mapping() {
830        assert_eq!(
831            pointer_id_from_touch_identifier(0, Some(0)),
832            Some(PointerId::PRIMARY)
833        );
834        assert_eq!(
835            pointer_id_from_touch_identifier(0, Some(1)),
836            PointerId::new(2)
837        );
838        assert_eq!(
839            pointer_id_from_touch_identifier(1, Some(1)),
840            Some(PointerId::PRIMARY)
841        );
842        assert_eq!(
843            pointer_id_from_touch_identifier(1, Some(0)),
844            PointerId::new(3)
845        );
846        assert_eq!(pointer_id_from_touch_identifier(-1, Some(0)), None);
847    }
848
849    #[test]
850    fn touch_count_clamps_to_u8() {
851        assert_eq!(pointer_attach_count_from_active_touches(0), 0);
852        assert_eq!(pointer_attach_count_from_active_touches(1), 1);
853        assert_eq!(pointer_attach_count_from_active_touches(255), 255);
854        assert_eq!(pointer_attach_count_from_active_touches(256), 255);
855        assert_eq!(pointer_attach_count_from_active_touches(u32::MAX), 255);
856    }
857}
858
859#[cfg(test)]
860mod stylus_orientation_tests {
861    use super::*;
862
863    fn assert_approx(a: f32, b: f32, eps: f32) {
864        assert!((a - b).abs() <= eps, "expected {a} ~= {b} (eps={eps})");
865    }
866
867    fn angle_wrap_pi(mut a: f32) -> f32 {
868        // Wrap to (-pi, pi].
869        const TWO_PI: f32 = core::f32::consts::PI * 2.0;
870        a = (a + core::f32::consts::PI).rem_euclid(TWO_PI) - core::f32::consts::PI;
871        if a <= -core::f32::consts::PI {
872            a += TWO_PI;
873        }
874        a
875    }
876
877    fn assert_azimuth_approx(a: f32, b: f32, eps: f32) {
878        let da = angle_wrap_pi(a - b).abs();
879        assert!(
880            da <= eps,
881            "expected azimuth {a} ~= {b} (|Δ|={da}, eps={eps})"
882        );
883    }
884
885    #[test]
886    fn perpendicular_tilt_maps_to_perpendicular_altitude() {
887        let o = pointer_orientation_from_tilt_degrees(0.0, 0.0);
888        assert!((o.altitude - core::f32::consts::FRAC_PI_2).abs() < 1e-6);
889    }
890
891    #[test]
892    fn azimuth_matches_axes() {
893        // Positive X => azimuth ~ 0
894        let o = pointer_orientation_from_tilt_degrees(30.0, 0.0);
895        assert_azimuth_approx(o.azimuth, 0.0, 1e-6);
896
897        // Negative X => azimuth ~ pi
898        let o = pointer_orientation_from_tilt_degrees(-30.0, 0.0);
899        assert_azimuth_approx(o.azimuth, core::f32::consts::PI, 1e-6);
900
901        // Positive Y => azimuth ~ pi/2
902        let o = pointer_orientation_from_tilt_degrees(0.0, 30.0);
903        assert_azimuth_approx(o.azimuth, core::f32::consts::FRAC_PI_2, 1e-6);
904
905        // Negative Y => azimuth ~ -pi/2
906        let o = pointer_orientation_from_tilt_degrees(0.0, -30.0);
907        assert_azimuth_approx(o.azimuth, -core::f32::consts::FRAC_PI_2, 1e-6);
908    }
909
910    #[test]
911    fn increasing_tilt_reduces_altitude() {
912        let o0 = pointer_orientation_from_tilt_degrees(0.0, 0.0);
913        let o1 = pointer_orientation_from_tilt_degrees(30.0, 0.0);
914        let o2 = pointer_orientation_from_tilt_degrees(60.0, 0.0);
915        assert!(o1.altitude < o0.altitude);
916        assert!(o2.altitude < o1.altitude);
917    }
918
919    #[test]
920    fn symmetry_negating_tilt_flips_azimuth_by_pi() {
921        let o = pointer_orientation_from_tilt_degrees(25.0, -10.0);
922        let o_neg = pointer_orientation_from_tilt_degrees(-25.0, 10.0);
923
924        assert_approx(o.altitude, o_neg.altitude, 1e-6);
925        assert_azimuth_approx(o_neg.azimuth, o.azimuth + core::f32::consts::PI, 1e-6);
926    }
927
928    #[test]
929    fn near_ninety_degree_tilt_is_finite_and_near_parallel() {
930        let o = pointer_orientation_from_tilt_degrees(89.9, 0.0);
931        assert!(o.altitude.is_finite());
932        assert!(o.azimuth.is_finite());
933        assert!(o.altitude < 0.01);
934
935        let o = pointer_orientation_from_tilt_degrees(-89.9, 0.0);
936        assert!(o.altitude.is_finite());
937        assert!(o.azimuth.is_finite());
938        assert!(o.altitude < 0.01);
939
940        let o = pointer_orientation_from_tilt_degrees(0.0, 89.9);
941        assert!(o.altitude.is_finite());
942        assert!(o.azimuth.is_finite());
943        assert!(o.altitude < 0.01);
944    }
945}