//! 3D mouse navigation on Windows (through hidapi) and Linux (through spacenavd when it runs, else //! hidraw). Read directly, any HID multi-axis controller works: 3Dconnexion's and homemade ones. //! The device reader never touches the document or UI; it publishes the latest axes, and the egui //! thread applies them once per frame. #[cfg(target_os = "linux")] mod hidraw; #[cfg(target_os = "linux")] mod spacenavd; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use egui::Context; #[cfg(target_os = "windows")] use hidapi::{DeviceInfo, HidApi}; use quick_xml::Reader; use quick_xml::events::Event; use solidcraft_space_mouse::Layout; use solidcraft_ui_egui::SolveApp; use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, DriverMapping, WheelGuard, navigate_frame}; const STALE_AFTER: Duration = Duration::from_millis(150); const RETRY_AFTER: Duration = Duration::from_secs(2); const MAX_PROFILE_BYTES: u64 = 1024 * 1024; /// Room for one input report: the 4096 bytes `solidcraft_space_mouse` places fields in, plus the /// report id. A smaller buffer would cut a longer report short, and a cut report is dropped whole. const REPORT_BUFFER_BYTES: usize = 4096 + 1; /// The mapping used when no 3DxWare profile is found: what the driver applies by default in /// object mode. On Linux there is no 3DxWare (#1172); on Windows 3DxWare has no profile for /// SolidCraft until one is made, and the raw axes swap roll and spin (#1380). fn without_profile() -> DriverMapping { DriverMapping::object_mode() } #[derive(Clone, Copy, Debug, Default)] struct ProfileAxis { input: Option, output: Option, enabled: bool, reversed: bool, scale: f64, } #[derive(Clone, Copy, Debug, PartialEq)] enum ProfilePart { Input, Output, } fn axis_index(name: &str) -> Option { match name.trim() { "HIDMultiAxis_X" => Some(0), "HIDMultiAxis_Y" => Some(1), "HIDMultiAxis_Z" => Some(2), "HIDMultiAxis_Rx" => Some(3), "HIDMultiAxis_Ry" => Some(4), "HIDMultiAxis_Rz" => Some(5), _ => None, } } /// 3DxWare stores the active KMJ application's axis selection, swapping, inversion and speed in /// `%APPDATA%/3Dconnexion/3DxWare/Cfg/-KMJ.xml`. Direct HID reads bypass the driver, so apply /// that profile explicitly when it exists. Unknown or incomplete fields retain the raw HID axis. fn parse_driver_mapping(xml: &[u8]) -> Option { let mut reader = Reader::from_reader(xml); reader.config_mut().trim_text(true); let mut buf = Vec::new(); let mut mapping = DriverMapping::default(); let mut axis: Option = None; let mut part = None; let mut field = Vec::new(); let mut found = false; loop { match reader.read_event_into(&mut buf).ok()? { Event::Start(start) => match start.local_name().as_ref() { b"Axis" => axis = Some(ProfileAxis { enabled: true, scale: 1.0, ..Default::default() }), b"Input" => part = Some(ProfilePart::Input), b"Output" => part = Some(ProfilePart::Output), name @ (b"ActionID" | b"Enabled" | b"Reversed" | b"Scale" | b"OverallScale") => field = name.to_vec(), _ => {} }, Event::Text(text) => { let value = String::from_utf8_lossy(text.as_ref()); match field.as_slice() { b"ActionID" => { if let Some(current) = axis.as_mut() { match part { Some(ProfilePart::Input) => current.input = axis_index(&value), Some(ProfilePart::Output) => current.output = axis_index(&value), None => {} } } } b"Enabled" => { if let Some(current) = axis.as_mut() { current.enabled = value.trim().eq_ignore_ascii_case("true"); } } b"Reversed" => { if part == Some(ProfilePart::Output) && let Some(current) = axis.as_mut() { current.reversed = value.trim().eq_ignore_ascii_case("true"); } } b"Scale" => { if part == Some(ProfilePart::Output) && let Some(scale) = value.trim().parse::().ok().filter(|scale| scale.is_finite()) && let Some(current) = axis.as_mut() { current.scale = scale.clamp(0.0, 10.0); } } b"OverallScale" => { if let Some(scale) = value.trim().parse::().ok().filter(|scale| scale.is_finite()) { mapping.overall_scale = scale.clamp(0.0, 10.0); } } _ => {} } } Event::End(end) => match end.local_name().as_ref() { b"Axis" => { if let Some(current) = axis.take() && let (Some(input), Some(output)) = (current.input, current.output) { mapping.axes[input] = AxisRule { output, enabled: current.enabled, reversed: current.reversed, scale: current.scale }; found = true; } } b"Input" | b"Output" => part = None, b"ActionID" | b"Enabled" | b"Reversed" | b"Scale" | b"OverallScale" => field.clear(), _ => {} }, Event::Eof => break, _ => {} } buf.clear(); } found.then_some(mapping) } fn load_driver_mapping() -> Option { let cfg = std::path::PathBuf::from(std::env::var_os("APPDATA")?).join("3Dconnexion").join("3DxWare").join("Cfg"); // The driver names the profile after the executable; one made for the pre-rename executable // still applies until the driver writes a new one (compatibility shim). let profile = ["solidcraft-KMJ.xml", "solvecraft-KMJ.xml"].into_iter().map(|n| cfg.join(n)).find(|p| p.is_file())?; if std::fs::metadata(&profile).ok()?.len() > MAX_PROFILE_BYTES { log::warn!("3D mouse: ignoring oversized 3DxWare profile {}", profile.display()); return None; } let xml = std::fs::read(&profile).ok()?; parse_driver_mapping(&xml) } #[derive(Clone, Copy)] struct Reading { axes: Axes, /// When each of X, Y, Z, Rx, Ry and Rz last arrived. Each ages on its own: a device may send /// translation and rotation in separate reports, or spread even one group over several. at: [Instant; 6], /// The source says when the cap is back at rest, so a reading stays until the next one. /// A HID device repeats its report while the cap is held; spacenavd sends a record only when /// the values change, and a steady hold can go seconds without one. held: bool, } impl Reading { fn new(now: Instant) -> Self { Self { axes: Axes::default(), at: [now; 6], held: false } } fn current(self, now: Instant) -> Axes { let mut axes = self.axes; if !self.held { for (value, at) in axes.translation.iter_mut().chain(axes.rotation.iter_mut()).zip(self.at) { if now.duration_since(at) >= STALE_AFTER { *value = 0; } } } axes } } /// The Generic Desktop "Multi-Axis Controller" usage names a 3D mouse's motion interface, whoever /// made it, and excludes ordinary mice and the keyboard interfaces of 3D mice. #[cfg(target_os = "windows")] fn is_3d_mouse(info: &DeviceInfo) -> bool { info.usage_page() == 0x01 && info.usage() == 0x08 } /// Takes the axes an input report carries, as the device's descriptor laid them out, and leaves /// the others as they were: translation and rotation may come in separate reports (3Dconnexion's /// wired devices) or one (its wireless ones and most homemade devices). fn decode_report(report: &[u8], layout: Layout, reading: &mut Reading, now: Instant) -> bool { let Some(decoded) = layout.decode(report) else { return false }; let values = reading.axes.translation.iter_mut().chain(reading.axes.rotation.iter_mut()); for ((value, at), new) in values.zip(reading.at.iter_mut()).zip(decoded.axes) { if let Some(new) = new { *value = new; *at = now; } } true } /// Publishes one input report from the device to the egui thread. fn publish(ctx: &Context, shared: &Mutex, layout: Layout, report: &[u8]) { if let Ok(mut reading) = shared.lock() && decode_report(report, layout, &mut reading, Instant::now()) { ctx.request_repaint(); } } /// Publishes the cap's position from spacenavd. #[cfg(target_os = "linux")] fn publish_held(ctx: &Context, shared: &Mutex, axes: Axes) { if let Ok(mut reading) = shared.lock() { *reading = Reading { axes, held: true, ..Reading::new(Instant::now()) }; ctx.request_repaint(); } } /// The device is gone: stop reporting its last position. fn reset(ctx: &Context, shared: &Mutex) { if let Ok(mut reading) = shared.lock() { *reading = Reading::new(Instant::now()); } ctx.request_repaint(); } #[cfg(target_os = "windows")] fn reader(ctx: Context, shared: Arc>) { let Ok(mut api) = HidApi::new() else { log::warn!("3D mouse: HID initialization failed"); return; }; loop { if let Err(error) = api.refresh_devices() { log::warn!("3D mouse: device scan failed: {error}"); } else if let Some((device, layout)) = api.device_list().filter(|info| is_3d_mouse(info)).find_map(|info| { let device = info.open_device(&api).ok()?; let layout = device_layout(&device, info.vendor_id())?; Some((device, layout)) }) { let mut report = [0_u8; REPORT_BUFFER_BYTES]; loop { match device.read_timeout(&mut report, 100) { Ok(0) => {} Ok(size) => publish(&ctx, &shared, layout, report.get(..size).unwrap_or_default()), Err(error) => { log::info!("3D mouse disconnected: {error}"); break; } } } reset(&ctx, &shared); } std::thread::sleep(RETRY_AFTER); } } /// How the device packs its axes, from its report descriptor, or None when it isn't one to read: /// a controller whose descriptor names no motion axis. Without a descriptor to go on (it can't be /// read, or it's empty), a 3Dconnexion device (Logitech's id for the old SpaceNavigator, /// 3Dconnexion's own for the rest) gets 3Dconnexion's layout, and any other device is left alone /// rather than guessed at. #[cfg(target_os = "windows")] fn device_layout(device: &hidapi::HidDevice, vendor_id: u16) -> Option { let mut descriptor = [0_u8; hidapi::MAX_REPORT_DESCRIPTOR_SIZE]; let read = device.get_report_descriptor(&mut descriptor); if let Ok(size @ 1..) = read { return solidcraft_space_mouse::Descriptor::parse(descriptor.get(..size).unwrap_or_default()).layout(); } let why = match read { Err(error) => error.to_string(), Ok(_) => "it is empty".to_string(), }; if matches!(vendor_id, 0x046d | 0x256f) { log::info!("3D mouse: no report descriptor ({why}), assuming separate translation and rotation reports"); Some(Layout::default()) } else { log::debug!("3D mouse: skipping a multi-axis controller without a report descriptor ({why})"); None } } /// Reads the 3D mouse through spacenavd when it runs, so what `spnavcfg` sets (swapped and inverted /// axes, sensitivity, dead zones) applies here as in other programs. Without the daemon it reads /// `/dev/hidrawN` with plain blocking reads, which end with an error when the device is unplugged. /// Opening needs permission: most distributions grant it to the logged-in user for devices that /// carry the `uaccess` udev tag, which a rule for the 3D mouse has to add. #[cfg(target_os = "linux")] fn reader(ctx: Context, shared: Arc>) { use std::io::Read; let mut denied = None; loop { if let Some(daemon) = spacenavd::connect() { log::info!("3D mouse: reading spacenavd, which applies the spnavcfg settings"); spacenavd::read(daemon, |axes| publish_held(&ctx, &shared, axes)); reset(&ctx, &shared); } else if let Some(device) = hidraw::find() { match std::fs::File::open(&device.path) { Ok(mut file) => { denied = None; log::info!("3D mouse: reading {}", device.path.display()); let mut report = [0_u8; REPORT_BUFFER_BYTES]; loop { match file.read(&mut report) { Ok(0) => break, Ok(size) => publish(&ctx, &shared, device.layout, report.get(..size).unwrap_or_default()), Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} Err(error) => { log::info!("3D mouse disconnected: {error}"); break; } } } reset(&ctx, &shared); } // Say so once per device node, not every retry. Err(error) if denied.as_ref() != Some(&device.path) => { log::warn!("3D mouse: cannot open {}: {error}. A udev rule granting the logged-in user access is needed.", device.path.display()); denied = Some(device.path); } Err(_) => {} } } std::thread::sleep(RETRY_AFTER); } } pub struct SpaceMouse { shared: Arc>, last_frame: Instant, driver_mapping: DriverMapping, mapping_checked_at: Instant, /// Drops 3DxWare's emulated wheel while the cap moves (#1380). wheel: WheelGuard, started: Instant, } impl SpaceMouse { pub fn start(ctx: &Context) -> Self { let now = Instant::now(); let shared = Arc::new(Mutex::new(Reading::new(now))); let worker_state = Arc::clone(&shared); let worker_ctx = ctx.clone(); if let Err(error) = std::thread::Builder::new().name("solidcraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) { log::warn!("3D mouse: reader could not start: {error}"); } Self { shared, last_frame: now, driver_mapping: load_driver_mapping().unwrap_or_else(without_profile), mapping_checked_at: now, wheel: WheelGuard::default(), started: now, } } /// Before the app's own hook (which adds the control channel's events): with navigation on, /// the wheel events 3DxWare makes from the cap's motion are dropped. pub fn raw_input_hook(&mut self, enabled: bool, raw: &mut egui::RawInput) { self.raw_input_hook_at(enabled, raw, Instant::now()); } fn raw_input_hook_at(&mut self, enabled: bool, raw: &mut egui::RawInput, now: Instant) { if !enabled { return; } let moving = self.shared.lock().map(|reading| reading.current(now).moving()).unwrap_or(false); self.wheel.filter(moving, now.duration_since(self.started).as_secs_f64(), &mut raw.events); } pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) { self.tick_at(app, ctx, Instant::now()); } /// The frame at `now`. Tests pass their own clock, so a slow runner can't age a reading. fn tick_at(&mut self, app: &mut SolveApp, ctx: &Context, now: Instant) { let seconds = now.duration_since(self.last_frame).as_secs_f64(); self.last_frame = now; if now.duration_since(self.mapping_checked_at) >= RETRY_AFTER { let updated = load_driver_mapping().unwrap_or_else(without_profile); if updated != self.driver_mapping { log::info!("3D mouse: applying updated 3DxWare axis profile"); self.driver_mapping = updated; } self.mapping_checked_at = now; } let axes = match self.shared.lock() { Ok(reading) => reading.current(now), Err(_) => return, }; navigate_frame(app, ctx, self.driver_mapping, axes, seconds); } } #[cfg(test)] mod tests { use super::*; use solidcraft_ui_egui::space_mouse::{DeviceMotion, Motion}; /// The logical axes a reading reaches. Logical Rz rolls the view and logical Ry turns it about /// its vertical (`navigation::device_camera` in solidcraft-ui-egui, tested there). fn rotation(mapping: DriverMapping, rotation: [i16; 3]) -> [f64; 3] { mapping.apply(Axes { translation: [0; 3], rotation }).rotation } #[test] fn each_axis_keeps_its_own_age_so_stale_motion_stops() { // Report layouts are tested in solidcraft-space-mouse; here, what the reader keeps. let now = Instant::now(); let mut reading = Reading::new(now); let layout = Layout::default(); assert!(!decode_report(&[1, 1, 2], layout, &mut reading, now)); assert_eq!(reading.current(now), Axes::default()); assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], layout, &mut reading, now)); assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], layout, &mut reading, now)); assert_eq!(reading.current(now), Axes { translation: [-100, 300, 0], rotation: [0, 1, -1] }); // Only rotation arrives again: translation goes stale, rotation stays current. let later = now + STALE_AFTER + Duration::from_millis(1); assert!(decode_report(&[2, 4, 0, 5, 0, 6, 0], layout, &mut reading, later)); assert_eq!(reading.current(later), Axes { translation: [0; 3], rotation: [4, 5, 6] }); // A device sending all six in one report updates both at once. let combined = solidcraft_space_mouse::Descriptor::parse(&[ 0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x06, 0x19, 0x30, 0x29, 0x35, 0x81, 0x02, 0xC0, ]) .layout() .expect("six axes declared"); let mut both = Reading::new(now); assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6, 0], combined, &mut both, now)); assert_eq!(both.current(now), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] }); } #[test] fn a_group_spread_over_reports_keeps_the_axes_it_already_has() { // X in report 1, Y in report 2 (16-bit, -350..350): X arriving mustn't zero Y, and Y // arriving mustn't zero X. let split_x_y = solidcraft_space_mouse::Descriptor::parse(&[ 0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x01, // 1 x 16 bit, -350..350 0x85, 0x01, 0x09, 0x30, 0x81, 0x02, // report 1: X 0x85, 0x02, 0x09, 0x31, 0x81, 0x02, // report 2: Y 0xC0, ]) .layout() .expect("X and Y declared"); let now = Instant::now(); let mut reading = Reading::new(now); assert!(decode_report(&[1, 100, 0], split_x_y, &mut reading, now)); assert!(decode_report(&[2, 200, 0], split_x_y, &mut reading, now)); assert_eq!(reading.current(now).translation, [100, 200, 0]); // Then only X keeps coming: Y goes stale on its own. let later = now + STALE_AFTER + Duration::from_millis(1); assert!(decode_report(&[1, 50, 0], split_x_y, &mut reading, later)); assert_eq!(reading.current(later).translation, [50, 0, 0]); } #[test] fn only_the_axis_itself_keeps_it_alive() { // 3Dconnexion's split layout, plus buttons in report 3 (one byte of bits). let mut layout = Layout::default(); layout.buttons[0] = Some(solidcraft_space_mouse::Field { report_id: 3, bit_offset: 0, bits: 1, logical_min: 0, logical_max: 1, relative: false }); let now = Instant::now(); let mut reading = Reading::new(now); assert!(decode_report(&[1, 100, 0, 0, 0, 0, 0], layout, &mut reading, now)); // Just before the timeout X is still there; at exactly the timeout it's stale. assert_eq!(reading.current(now + STALE_AFTER - Duration::from_millis(1)).translation, [100, 0, 0]); assert_eq!(reading.current(now + STALE_AFTER).translation, [0; 3]); // Button reports and reports with an id the layout doesn't know keep coming, but neither // refreshes X: it goes stale on time all the same. for step in 1..=10 { let at = now + STALE_AFTER * step / 10; assert!(decode_report(&[3, 1], layout, &mut reading, at)); assert!(!decode_report(&[0x17, 80], layout, &mut reading, at)); } assert_eq!(reading.current(now + STALE_AFTER).translation, [0; 3]); } #[test] fn a_held_reading_stays_until_the_next_one_and_a_hid_reading_goes_stale() { let now = Instant::now(); let axes = Axes { translation: [0, 200, 0], rotation: [0; 3] }; let later = now + STALE_AFTER * 20; // spacenavd sends nothing while the values stay the same: the push lasts. let held = Reading { axes, held: true, ..Reading::new(now) }; assert_eq!(held.current(later), axes); // A HID device repeats its report: silence means the cap, or the device, is gone. let hid = Reading { axes, ..Reading::new(now) }; assert_eq!(hid.current(later), Axes::default()); } #[test] fn applies_3dxware_axis_swaps_and_reversals() { let profile = br#" trueHIDMultiAxis_XHIDMultiAxis_Xfalse trueHIDMultiAxis_YHIDMultiAxis_Zfalse trueHIDMultiAxis_ZHIDMultiAxis_Ytrue trueHIDMultiAxis_RxHIDMultiAxis_Rxfalse trueHIDMultiAxis_RyHIDMultiAxis_Rzfalse trueHIDMultiAxis_RzHIDMultiAxis_Rytrue "#; let mapping = parse_driver_mapping(profile).expect("valid 3DxWare profile"); let motion = mapping.apply(Axes { translation: [350, 0, 350], rotation: [350, 0, 350] }); assert_eq!(motion.translation, [1.0, -1.0, 0.0]); assert_eq!(motion.rotation, [1.0, -1.0, 0.0]); } #[test] fn without_a_profile_the_drivers_object_mode_axes_are_used() { // 3DxWare's object-mode mapping, as a profile (#1172: confirmed on a SpaceMouse Compact). let object_mode = br#" trueHIDMultiAxis_XHIDMultiAxis_Xfalse trueHIDMultiAxis_YHIDMultiAxis_Yfalse trueHIDMultiAxis_ZHIDMultiAxis_Zfalse trueHIDMultiAxis_RxHIDMultiAxis_Rxfalse trueHIDMultiAxis_RyHIDMultiAxis_Rztrue trueHIDMultiAxis_RzHIDMultiAxis_Rytrue "#; let profile = parse_driver_mapping(object_mode).expect("valid 3DxWare profile"); // On every platform (#1172 on Linux, #1380 on Windows). assert_eq!(without_profile(), profile); // Tilting the cap rolls the view and twisting it spins it, as with the driver. assert_eq!(rotation(without_profile(), [0, 350, 0]), [0.0, 0.0, -1.0]); assert_eq!(rotation(without_profile(), [0, 0, 350]), [0.0, -1.0, 0.0]); } #[test] fn mapped_roll_and_spin_reach_the_roll_and_turn_axes() { let profile = br#" trueHIDMultiAxis_RyHIDMultiAxis_Rzfalse trueHIDMultiAxis_RzHIDMultiAxis_Rytrue "#; let mapping = parse_driver_mapping(profile).expect("valid 3DxWare profile"); // The physical roll reaches the roll axis only, and the physical spin the turn only. assert_eq!(rotation(mapping, [0, 350, 0]), [0.0, 0.0, 1.0]); assert_eq!(rotation(mapping, [0, 0, 350]), [0.0, -1.0, 0.0]); } #[test] fn each_frame_hands_the_view_its_motion_through_the_drivers_mapping() { // The reader's latest tilt, and a profile that makes tilt roll: the frame's motion rolls. // The frame runs at the reading's own time, so a slow runner can't let the reading go stale. let mut app = SolveApp::new(solidcraft_engine::Session::default(), solidcraft_ui_egui::Services::default()); app.home.open = false; let now = Instant::now(); let mut reading = Reading::new(now); reading.axes.rotation = [0, 350, 0]; let mut mouse = SpaceMouse { shared: Arc::new(Mutex::new(reading)), last_frame: now - Duration::from_millis(20), driver_mapping: DriverMapping::object_mode(), mapping_checked_at: now, wheel: WheelGuard::default(), started: now, }; mouse.tick_at(&mut app, &Context::default(), now); let DeviceMotion { motion, seconds } = app.viewport.device_motion.expect("the frame hands the page its motion"); assert_eq!(motion, Motion { translation: [0.0; 3], rotation: [0.0, 0.0, -1.0] }); assert!(seconds > 0.0); } fn wheel() -> egui::Event { egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: egui::vec2(0.0, -1.0), modifiers: Default::default(), phase: egui::TouchPhase::Move, } } fn mouse(reading: Reading, now: Instant) -> SpaceMouse { SpaceMouse { shared: Arc::new(Mutex::new(reading)), last_frame: now, driver_mapping: DriverMapping::object_mode(), mapping_checked_at: now, wheel: WheelGuard::default(), started: now, } } #[test] fn the_wheel_3dxware_makes_from_the_cap_is_dropped() { // The cap tilted forward: 3DxWare also sends wheel turns (#1380). They are dropped. let now = Instant::now(); let mut tilted = Reading::new(now); tilted.axes.rotation = [-200, 0, 0]; let mut moving = mouse(tilted, now); let mut raw = egui::RawInput { events: vec![wheel(), egui::Event::Zoom(1.1), egui::Event::Text("a".into())], ..Default::default() }; moving.raw_input_hook_at(true, &mut raw, now); assert_eq!(raw.events, [egui::Event::Text("a".into())]); // With navigation off, the wheel is the cap's only way to move the view: kept. let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() }; moving.raw_input_hook_at(false, &mut raw, now); assert_eq!(raw.events.len(), 1); // The cap at rest: the wheel is the user's. let mut resting = mouse(Reading::new(now), now); let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() }; resting.raw_input_hook_at(true, &mut raw, now); assert_eq!(raw.events.len(), 1); } #[test] fn the_wheel_is_the_users_again_once_the_cap_has_been_still_a_moment() { let now = Instant::now(); let mut tilted = Reading::new(now); tilted.axes.rotation = [-200, 0, 0]; let mut cap = mouse(tilted, now); let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() }; cap.raw_input_hook_at(true, &mut raw, now); assert!(raw.events.is_empty(), "dropped while the cap moves"); // Let go, then wait out the quiet time. let later = now + Duration::from_secs_f64(solidcraft_ui_egui::space_mouse::WHEEL_QUIET_S) + Duration::from_millis(60); if let Ok(mut reading) = cap.shared.lock() { *reading = Reading::new(later); } let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() }; cap.raw_input_hook_at(true, &mut raw, later); assert_eq!(raw.events.len(), 1, "kept once the cap has been still"); } }