//! Pen and touch input (an iPad with an Apple Pencil): the shell reads Pointer Events itself, in //! the capture phase, before eframe's own touch and pointer handlers, which can't tell a pen from //! a finger and see each touch twice (as a pointer and as a touch event). //! //! - A pen drives egui's pointer and is a touch contact with the pen's pressure as its force (the //! canvas reads it), so holding it still opens the context menu (egui's long touch). It hovers //! where the pen can (Apple Pencil hover). //! - Fingers are touch contacts: two pinch and pan the canvas, and tap to undo or redo. The first //! finger also drives the pointer, so it taps the panels and menus; on the canvas it navigates //! only once a pen has been seen ([`touch::set_source`]), for this page session only. //! - Palm rejection: while a pen is down, fingers are ignored; a pen landing cancels the fingers //! already down (a palm resting first). //! //! Mouse and trackpad events (a Magic Keyboard) are left to eframe. Until the first pen //! contact, fingers and their DOM touch events also stay with eframe (including its text agent). #[cfg(target_arch = "wasm32")] use std::cell::RefCell; #[cfg(target_arch = "wasm32")] use std::rc::Rc; use egui::{Event, PointerButton, Pos2, TouchDeviceId, TouchId, TouchPhase}; use vectorcraft_ui_egui::touch::{self, Source}; #[cfg(target_arch = "wasm32")] use wasm_bindgen::JsCast as _; /// The pen's touch device (fingers use another, so egui doesn't pinch a pen with a finger). const PEN_DEVICE: TouchDeviceId = TouchDeviceId(1); const FINGER_DEVICE: TouchDeviceId = TouchDeviceId(2); /// The pen and finger contacts followed, and the egui events they made since the last frame. #[derive(Default)] pub struct Pointers { events: Vec, /// The pen pressing, by pointer id. pen: Option, /// The fingers down, by pointer id. fingers: Vec, /// The contact driving egui's pointer. primary: Option, button: Option, } #[cfg(target_arch = "wasm32")] pub type SharedPointers = Rc>; /// Browser-independent snapshot, also used by the routing regression tests. struct Input<'a> { pointer_type: &'a str, id: i32, button: i16, pressure: f32, modifiers: egui::Modifiers, } // Match eframe's button_from_mouse_event, including barrel/right and auxiliary buttons. fn button(number: i16) -> Option { match number { 0 => Some(PointerButton::Primary), 1 => Some(PointerButton::Middle), 2 => Some(PointerButton::Secondary), 3 => Some(PointerButton::Extra1), 4 => Some(PointerButton::Extra2), _ => None, } } impl Pointers { /// The events since the last frame, for the frame's raw input. pub fn take(&mut self) -> Vec { std::mem::take(&mut self.events) } fn touch(&mut self, device: TouchDeviceId, id: i32, phase: TouchPhase, pos: Pos2, force: Option) { self.events.push(Event::Touch { device_id: device, id: TouchId::from(id as u64), phase, pos, force }); } fn press(&mut self, id: i32, pos: Pos2, button: PointerButton, modifiers: egui::Modifiers) { self.primary = Some(id); self.button = Some(button); self.events.push(Event::ModifiersChanged(modifiers)); self.events.push(Event::PointerMoved(pos)); self.events.push(Event::PointerButton { pos, button, pressed: true, modifiers }); } fn release(&mut self, pos: Pos2, modifiers: egui::Modifiers) { self.primary = None; if let Some(button) = self.button.take() { self.events.push(Event::ModifiersChanged(modifiers)); self.events.push(Event::PointerButton { pos, button, pressed: false, modifiers }); } } /// Cancel every finger down (a palm under a pen landing). fn cancel_fingers(&mut self, pos: Pos2, modifiers: egui::Modifiers) { for id in std::mem::take(&mut self.fingers) { self.touch(FINGER_DEVICE, id, TouchPhase::Cancel, pos, None); if self.primary == Some(id) { self.release(pos, modifiers); self.events.push(Event::PointerGone); } } } /// The paired DOM touch events must follow the same routing as pointer events. fn captures_touch(&self, on_canvas: bool) -> bool { touch::pen_seen() && (on_canvas || self.pen.is_some() || !self.fingers.is_empty()) } /// Follow one pointer event; `true` if it was the pen's or a finger's (taken from eframe). fn feed(&mut self, kind: &str, e: &Input<'_>, pos: Pos2, coalesced: &[(Pos2, f32)], on_canvas: bool) -> bool { let id = e.id; match e.pointer_type { "pen" => { let force = Some(e.pressure.clamp(0.0, 1.0)); match kind { "pointerdown" => { if !on_canvas && self.pen.is_none() { return false; } let Some(button) = button(e.button) else { return false }; touch::set_source(Source::Pen); self.cancel_fingers(pos, e.modifiers); self.pen = Some(id); self.touch(PEN_DEVICE, id, TouchPhase::Start, pos, force); self.press(id, pos, button, e.modifiers); } "pointermove" if self.pen == Some(id) => { // The positions the pen reported between two events (up to 240 a second // on an iPad), each with its pressure, so fast strokes keep their curve. for c in coalesced.iter().take(coalesced.len().saturating_sub(1)) { self.touch(PEN_DEVICE, id, TouchPhase::Move, c.0, Some(c.1)); self.events.push(Event::PointerMoved(c.0)); } self.touch(PEN_DEVICE, id, TouchPhase::Move, pos, force); self.events.push(Event::PointerMoved(pos)); } // Hovering (Apple Pencil hover): the pointer follows without pressing. "pointermove" => { if self.primary.is_some() { return true; } if !on_canvas { return false; } self.events.push(Event::PointerMoved(pos)); } "pointerup" | "pointercancel" if self.pen == Some(id) => { let phase = if kind == "pointerup" { TouchPhase::End } else { TouchPhase::Cancel }; self.pen = None; self.touch(PEN_DEVICE, id, phase, pos, None); self.release(pos, e.modifiers); } // A pen leaving the hover range, or an event of a press we didn't take. _ => { if kind == "pointerleave" && self.primary.is_none() && on_canvas { self.events.push(Event::PointerGone); } return self.pen.is_some(); } } true } // Keep eframe's touch/IME path unchanged on touch-only devices. Reloading the // page resets this policy; an old localStorage marker is deliberately ignored. "touch" if !touch::pen_seen() => false, "touch" => { match kind { "pointerdown" => { // Palm rejection: nothing presses while the pen does. if self.pen.is_some() { return true; } if !on_canvas && self.fingers.is_empty() { return false; } self.fingers.push(id); self.touch(FINGER_DEVICE, id, TouchPhase::Start, pos, None); if self.primary.is_none() && self.fingers.len() == 1 { touch::set_source(Source::Finger); self.press(id, pos, PointerButton::Primary, e.modifiers); } } "pointermove" if self.fingers.contains(&id) => { self.touch(FINGER_DEVICE, id, TouchPhase::Move, pos, None); if self.primary == Some(id) { self.events.push(Event::PointerMoved(pos)); } } "pointerup" | "pointercancel" if self.fingers.contains(&id) => { let phase = if kind == "pointerup" { TouchPhase::End } else { TouchPhase::Cancel }; self.fingers.retain(|f| *f != id); self.touch(FINGER_DEVICE, id, phase, pos, None); if self.primary == Some(id) { self.release(pos, e.modifiers); self.events.push(Event::PointerGone); } } // A rejected palm's later events, or one we never took. _ => return self.pen.is_some() || !self.fingers.is_empty() || on_canvas, } true } "mouse" => { if kind == "pointerdown" { touch::set_source(Source::Mouse); } false } _ => false, } } } /// Take pen and finger Pointer Events on the page from eframe into `pointers` (and the touch /// events that follow them), for the runner's frames. Mouse events stay eframe's. #[cfg(target_arch = "wasm32")] pub fn install(runner: &eframe::WebRunner, canvas: &web_sys::HtmlCanvasElement, pointers: &SharedPointers) -> Result<(), wasm_bindgen::JsValue> { let window: web_sys::EventTarget = web_sys::window().ok_or("no window")?.into(); let options = web_sys::AddEventListenerOptions::new(); options.set_capture(true); options.set_passive(false); for kind in ["pointerdown", "pointermove", "pointerup", "pointercancel", "pointerleave"] { let (pointers, canvas) = (pointers.clone(), canvas.clone()); runner.add_event_listener_ex(&window, kind, &options, move |e: web_sys::PointerEvent, runner, _| { let on_canvas = e.target().is_some_and(|t| &t == canvas.unchecked_ref::()); let rect = canvas.get_bounding_client_rect(); let z = runner.egui_ctx().zoom_factor(); let pos = egui::pos2((e.client_x() as f32 - rect.left() as f32) / z, (e.client_y() as f32 - rect.top() as f32) / z); let at = |x: i32, y: i32| egui::pos2((x as f32 - rect.left() as f32) / z, (y as f32 - rect.top() as f32) / z); let coalesced: Vec<(Pos2, f32)> = if kind == "pointermove" && e.pointer_type() == "pen" // Safari before 17 hasn't it, and calling a missing method would abort the app. && js_sys::Reflect::has(&e, &"getCoalescedEvents".into()).unwrap_or(false) { e.get_coalesced_events() .iter() .filter_map(|c| c.dyn_into::().ok()) .map(|c| (at(c.client_x(), c.client_y()), c.pressure().clamp(0.0, 1.0))) .collect() } else { vec![] }; let pointer_type = e.pointer_type(); let input = Input { pointer_type: &pointer_type, id: e.pointer_id(), button: e.button(), pressure: e.pressure(), // Same semantics as eframe's modifiers_from_mouse_event. modifiers: egui::Modifiers { alt: e.alt_key(), ctrl: e.ctrl_key(), shift: e.shift_key(), mac_cmd: e.meta_key(), command: e.ctrl_key() || e.meta_key(), }, }; if !pointers.borrow_mut().feed(kind, &input, pos, &coalesced, on_canvas) { return; } e.stop_immediate_propagation(); // No compatibility mouse events, no text selection or magnifier. e.prevent_default(); if kind == "pointerup" { // Keys typed next (a Magic Keyboard) go to the app. canvas.focus().ok(); // Safari lets a page open the share sheet, a download or the on-screen keyboard // only during the user's gesture: handle the release now. runner.logic(); } runner.egui_ctx().request_repaint(); })?; } // The touch events behind those pointer events: eframe would see each touch a second time, // and Safari would scroll, zoom or select. for kind in ["touchstart", "touchmove", "touchend", "touchcancel"] { let (pointers, canvas) = (pointers.clone(), canvas.clone()); runner.add_event_listener_ex(&window, kind, &options, move |e: web_sys::TouchEvent, _runner, _| { let on_canvas = e.target().is_some_and(|t| &t == canvas.unchecked_ref::()); let p = pointers.borrow(); if p.captures_touch(on_canvas) { e.stop_immediate_propagation(); if e.cancelable() { e.prevent_default(); } } })?; } Ok(()) } #[cfg(test)] mod tests { use super::*; fn input(pointer_type: &str, button: i16) -> Input<'_> { Input { pointer_type, id: 7, button, pressure: 0.6, modifiers: egui::Modifiers::default() } } fn reset() { touch::set_pen_seen(false); touch::set_source(Source::Mouse); } #[test] fn pen_buttons_and_modifiers_survive_press_release_and_cancel() { for (number, expected) in [ (0, PointerButton::Primary), (1, PointerButton::Middle), (2, PointerButton::Secondary), (3, PointerButton::Extra1), (4, PointerButton::Extra2), ] { for end in ["pointerup", "pointercancel"] { reset(); let mut p = Pointers::default(); let mut e = input("pen", number); e.modifiers = egui::Modifiers { shift: true, ctrl: true, alt: true, mac_cmd: true, command: true }; let pos = Pos2::new(30.0, 40.0); assert!(p.feed("pointerdown", &e, pos, &[], true)); assert!(p.feed(end, &e, pos, &[], false)); let buttons: Vec<_> = p .take() .into_iter() .filter_map(|e| match e { Event::PointerButton { button, pressed, modifiers, .. } => Some((button, pressed, modifiers)), _ => None, }) .collect(); assert_eq!(buttons, [(expected, true, e.modifiers), (expected, false, e.modifiers)]); assert!(p.pen.is_none() && p.primary.is_none()); } } reset(); } #[test] fn touch_only_and_mouse_input_stay_with_eframe_and_session_can_reset() { reset(); let mut p = Pointers::default(); let pos = Pos2::new(10.0, 20.0); for pointer_type in ["touch", "mouse"] { for kind in ["pointerdown", "pointermove", "pointerup", "pointercancel"] { assert!(!p.feed(kind, &input(pointer_type, 0), pos, &[], true)); } } assert!(p.take().is_empty()); assert!(!p.captures_touch(true), "eframe must also receive DOM touch events before a pen"); assert!(p.feed("pointerdown", &input("pen", 0), pos, &[], true)); assert!(p.feed("pointerup", &input("pen", 0), pos, &[], true)); assert!(p.captures_touch(true)); assert!(p.feed("pointerdown", &input("touch", 0), pos, &[], true)); assert!(p.feed("pointerup", &input("touch", 0), pos, &[], true)); // A new page starts from false, without reading any old persistent marker. reset(); let mut next_page = Pointers::default(); assert!(!next_page.feed("pointerdown", &input("touch", 0), pos, &[], true)); assert!(next_page.take().is_empty()); assert!(!next_page.captures_touch(true)); } #[test] fn finger_modifiers_and_palm_cancellation_are_preserved() { reset(); touch::set_source(Source::Pen); let mut p = Pointers::default(); let pos = Pos2::new(10.0, 20.0); let mut finger = input("touch", 0); finger.id = 10; finger.modifiers.shift = true; assert!(p.feed("pointerdown", &finger, pos, &[], true)); assert!(p.take().iter().any(|e| matches!(e, Event::PointerButton { pressed: true, modifiers, .. } if modifiers.shift))); let pen = input("pen", 0); assert!(p.feed("pointerdown", &pen, pos, &[], true)); assert!(p.take().iter().any(|e| matches!(e, Event::Touch { phase: TouchPhase::Cancel, .. }))); assert!(p.fingers.is_empty()); assert!(p.feed("pointermove", &finger, pos, &[], true)); assert!(p.take().is_empty(), "a resting palm cannot move the pen"); assert!(p.feed("pointerup", &pen, pos, &[], true)); assert!(p.feed("pointerdown", &finger, pos, &[], true)); assert!(p.feed("pointerup", &finger, pos, &[], true)); assert!(p.take().iter().any(|e| matches!(e, Event::PointerButton { pressed: false, modifiers, .. } if modifiers.shift))); reset(); } #[test] fn unknown_pen_buttons_are_not_reinterpreted_as_primary() { reset(); let mut p = Pointers::default(); assert!(!p.feed("pointerdown", &input("pen", 5), Pos2::ZERO, &[], true)); assert!(p.take().is_empty()); assert!(!touch::pen_seen()); } }