From b0c568a78e9ac943e97ba63e64dcd2ceba76a087 Mon Sep 17 00:00:00 2001 From: Alan Everett Date: Thu, 11 Sep 2025 06:44:17 -0400 Subject: [PATCH] Add rotation gesture support for trackpad sources (#7453) Co-authored-by: Emil Ernerfeldt Co-authored-by: Lucas Meurer --- crates/eframe/src/web/backend.rs | 8 ++- crates/eframe/src/web/events.rs | 71 ++++++++++++++++++- crates/egui-winit/src/lib.rs | 31 +++++++- crates/egui/src/data/input.rs | 3 + crates/egui/src/input_state/mod.rs | 31 ++++++++ crates/egui_demo_lib/src/demo/multi_touch.rs | 48 +++++++++---- .../tests/snapshots/demos/Multi Touch.png | 4 +- 7 files changed, 174 insertions(+), 22 deletions(-) diff --git a/crates/eframe/src/web/backend.rs b/crates/eframe/src/web/backend.rs index e81e5487..4814fa99 100644 --- a/crates/eframe/src/web/backend.rs +++ b/crates/eframe/src/web/backend.rs @@ -11,9 +11,15 @@ use super::percent_decode; /// Data gathered between frames. #[derive(Default)] pub(crate) struct WebInput { - /// Required because we don't get a position on touched + /// Required because we don't get a position on touchend pub primary_touch: Option, + /// Helps to track the delta scale from gesture events + pub accumulated_scale: f32, + + /// Helps to track the delta rotation from gesture events + pub accumulated_rotation: f32, + /// The raw input to `egui`. pub raw: egui::RawInput, } diff --git a/crates/eframe/src/web/events.rs b/crates/eframe/src/web/events.rs index a5bb4c0e..dff1463c 100644 --- a/crates/eframe/src/web/events.rs +++ b/crates/eframe/src/web/events.rs @@ -7,6 +7,7 @@ use super::{ push_touches, text_from_keyboard_event, translate_key, }; +use js_sys::Reflect; use web_sys::{Document, EventTarget, ShadowRoot}; // TODO(emilk): there are more calls to `prevent_default` and `stop_propagation` @@ -101,6 +102,7 @@ pub(crate) fn install_event_handlers(runner_ref: &WebRunner) -> Result<(), JsVal install_touchcancel(runner_ref, &canvas)?; install_wheel(runner_ref, &canvas)?; + install_gesture(runner_ref, &canvas)?; install_drag_and_drop(runner_ref, &canvas)?; install_window_events(runner_ref, &window)?; install_color_scheme_change_event(runner_ref, &window)?; @@ -816,7 +818,7 @@ fn install_wheel(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsV let egui_event = if modifiers.ctrl && !runner.input.raw.modifiers.ctrl { // The browser is saying the ctrl key is down, but it isn't _really_. - // This happens on pinch-to-zoom on a Mac trackpad. + // This happens on pinch-to-zoom on multitouch trackpads // egui will treat ctrl+scroll as zoom, so it all works. // However, we explicitly handle it here in order to better match the pinch-to-zoom // speed of a native app, without being sensitive to egui's `scroll_zoom_speed` setting. @@ -847,6 +849,73 @@ fn install_wheel(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsV }) } +fn install_gesture(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> { + runner_ref.add_event_listener(target, "gesturestart", |event: web_sys::Event, runner| { + runner.input.accumulated_scale = 1.0; + runner.input.accumulated_rotation = 0.0; + handle_gesture(event, runner); + })?; + runner_ref.add_event_listener(target, "gesturechange", handle_gesture)?; + runner_ref.add_event_listener(target, "gestureend", |event: web_sys::Event, runner| { + handle_gesture(event, runner); + runner.input.accumulated_scale = 1.0; + runner.input.accumulated_rotation = 0.0; + })?; + + Ok(()) +} + +#[expect(clippy::needless_pass_by_value)] // So that we can pass it directly to `add_event_listener` +fn handle_gesture(event: web_sys::Event, runner: &mut AppRunner) { + // GestureEvent is a non-standard API, so this attempts to get the relevant fields if they exist. + let new_scale = Reflect::get(&event, &JsValue::from_str("scale")) + .ok() + .and_then(|scale| scale.as_f64()) + .map_or(1.0, |scale| scale as f32); + let new_rotation = Reflect::get(&event, &JsValue::from_str("rotation")) + .ok() + .and_then(|rotation| rotation.as_f64()) + .map_or(0.0, |rotation| rotation.to_radians() as f32); + + let scale_delta = new_scale / runner.input.accumulated_scale; + let rotation_delta = new_rotation - runner.input.accumulated_rotation; + runner.input.accumulated_scale *= scale_delta; + runner.input.accumulated_rotation += rotation_delta; + + let mut should_stop_propagation = true; + let mut should_prevent_default = true; + + if scale_delta != 1.0 { + let zoom_event = egui::Event::Zoom(scale_delta); + + should_stop_propagation &= (runner.web_options.should_stop_propagation)(&zoom_event); + should_prevent_default &= (runner.web_options.should_prevent_default)(&zoom_event); + runner.input.raw.events.push(zoom_event); + } + + if rotation_delta != 0.0 { + let rotate_event = egui::Event::Rotate(rotation_delta); + + should_stop_propagation &= (runner.web_options.should_stop_propagation)(&rotate_event); + should_prevent_default &= (runner.web_options.should_prevent_default)(&rotate_event); + runner.input.raw.events.push(rotate_event); + } + + if scale_delta != 1.0 || rotation_delta != 0.0 { + runner.needs_repaint.repaint_asap(); + + // Use web options to tell if the web event should be propagated to parent elements based on the egui event. + if should_stop_propagation { + event.stop_propagation(); + } + + if should_prevent_default { + // Prevents a simulated ctrl-scroll event for zoom + event.prevent_default(); + } + } +} + fn install_drag_and_drop(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> { runner_ref.add_event_listener(target, "dragover", |event: web_sys::DragEvent, runner| { if let Some(data_transfer) = event.data_transfer() { diff --git a/crates/egui-winit/src/lib.rs b/crates/egui-winit/src/lib.rs index 206b6bd3..99e72c82 100644 --- a/crates/egui-winit/src/lib.rs +++ b/crates/egui-winit/src/lib.rs @@ -491,9 +491,7 @@ impl State { // Things we completely ignore: WindowEvent::ActivationTokenDone { .. } | WindowEvent::AxisMotion { .. } - | WindowEvent::DoubleTapGesture { .. } - | WindowEvent::RotationGesture { .. } - | WindowEvent::PanGesture { .. } => EventResponse { + | WindowEvent::DoubleTapGesture { .. } => EventResponse { repaint: false, consumed: false, }, @@ -508,6 +506,33 @@ impl State { consumed: self.egui_ctx.wants_pointer_input(), } } + + WindowEvent::RotationGesture { delta, .. } => { + // Positive delta values indicate counterclockwise rotation + // Negative delta values indicate clockwise rotation + // This is opposite of egui's sign convention for angles + self.egui_input + .events + .push(egui::Event::Rotate(-delta.to_radians())); + EventResponse { + repaint: true, + consumed: self.egui_ctx.wants_pointer_input(), + } + } + + WindowEvent::PanGesture { delta, .. } => { + let pixels_per_point = pixels_per_point(&self.egui_ctx, window); + + self.egui_input.events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: Vec2::new(delta.x, delta.y) / pixels_per_point, + modifiers: self.egui_input.modifiers, + }); + EventResponse { + repaint: true, + consumed: self.egui_ctx.wants_pointer_input(), + } + } } } diff --git a/crates/egui/src/data/input.rs b/crates/egui/src/data/input.rs index 6e6a3deb..7c23a13b 100644 --- a/crates/egui/src/data/input.rs +++ b/crates/egui/src/data/input.rs @@ -479,6 +479,9 @@ pub enum Event { /// As a user, check [`crate::InputState::smooth_scroll_delta`] to see if the user did any zooming this frame. Zoom(f32), + /// Rotation in radians this frame, measuring clockwise (e.g. from a rotation gesture). + Rotate(f32), + /// IME Event Ime(ImeEvent), diff --git a/crates/egui/src/input_state/mod.rs b/crates/egui/src/input_state/mod.rs index bfa20d6c..33a08c79 100644 --- a/crates/egui/src/input_state/mod.rs +++ b/crates/egui/src/input_state/mod.rs @@ -267,6 +267,9 @@ pub struct InputState { /// * `zoom > 1`: pinch spread zoom_factor_delta: f32, + /// Rotation in radians this frame, measuring clockwise (e.g. from a rotation gesture). + rotation_radians: f32, + // ---------------------------------------------- /// Position and size of the egui area. pub screen_rect: Rect, @@ -354,6 +357,7 @@ impl Default for InputState { raw_scroll_delta: Vec2::ZERO, smooth_scroll_delta: Vec2::ZERO, zoom_factor_delta: 1.0, + rotation_radians: 0.0, screen_rect: Rect::from_min_size(Default::default(), vec2(10_000.0, 10_000.0)), pixels_per_point: 1.0, @@ -402,6 +406,7 @@ impl InputState { let mut keys_down = self.keys_down; let mut zoom_factor_delta = 1.0; // TODO(emilk): smoothing for zoom factor + let mut rotation_radians = 0.0; let mut raw_scroll_delta = Vec2::ZERO; let mut unprocessed_scroll_delta = self.unprocessed_scroll_delta; @@ -480,6 +485,9 @@ impl InputState { Event::Zoom(factor) => { zoom_factor_delta *= *factor; } + Event::Rotate(radians) => { + rotation_radians += *radians; + } Event::WindowFocused(false) => { // Example: pressing `Cmd+S` brings up a save-dialog (e.g. using rfd), // but we get no key-up event for the `S` key (in winit). @@ -542,6 +550,7 @@ impl InputState { raw_scroll_delta, smooth_scroll_delta, zoom_factor_delta, + rotation_radians, screen_rect, pixels_per_point, @@ -631,6 +640,26 @@ impl InputState { } } + /// Rotation in radians this frame, measuring clockwise (e.g. from a rotation gesture). + #[inline(always)] + pub fn rotation_delta(&self) -> f32 { + self.multi_touch() + .map_or(self.rotation_radians, |touch| touch.rotation_delta) + } + + /// Panning translation in pixels this frame (e.g. from scrolling or a pan gesture) + /// + /// The delta indicates how the **content** should move. + /// + /// A positive X-value indicates the content is being moved right, as when swiping right on a touch-screen or track-pad with natural scrolling. + /// + /// A positive Y-value indicates the content is being moved down, as when swiping down on a touch-screen or track-pad with natural scrolling. + #[inline(always)] + pub fn translation_delta(&self) -> Vec2 { + self.multi_touch() + .map_or(self.smooth_scroll_delta, |touch| touch.translation_delta) + } + /// How long has it been (in seconds) since the use last scrolled? #[inline(always)] pub fn time_since_last_scroll(&self) -> f32 { @@ -1526,6 +1555,7 @@ impl InputState { unprocessed_scroll_delta_for_zoom, raw_scroll_delta, smooth_scroll_delta, + rotation_radians, zoom_factor_delta, screen_rect, @@ -1579,6 +1609,7 @@ impl InputState { "smooth_scroll_delta: {smooth_scroll_delta:?} points" )); ui.label(format!("zoom_factor_delta: {zoom_factor_delta:4.2}x")); + ui.label(format!("rotation_radians: {rotation_radians:.3} radians")); ui.label(format!("screen_rect: {screen_rect:?} points")); ui.label(format!( diff --git a/crates/egui_demo_lib/src/demo/multi_touch.rs b/crates/egui_demo_lib/src/demo/multi_touch.rs index 0c2d9820..d83e548b 100644 --- a/crates/egui_demo_lib/src/demo/multi_touch.rs +++ b/crates/egui_demo_lib/src/demo/multi_touch.rs @@ -1,5 +1,5 @@ use egui::{ - Color32, Frame, Pos2, Rect, Sense, Stroke, Vec2, + Color32, Event, Frame, Pos2, Rect, Sense, Stroke, Vec2, emath::{RectTransform, Rot2}, vec2, }; @@ -30,7 +30,7 @@ impl crate::Demo for MultiTouch { fn show(&mut self, ctx: &egui::Context, open: &mut bool) { egui::Window::new(self.name()) .open(open) - .default_size(vec2(512.0, 512.0)) + .default_size(vec2(544.0, 512.0)) .resizable(true) .show(ctx, |ui| { use crate::View as _; @@ -45,13 +45,31 @@ impl crate::View for MultiTouch { ui.add(crate::egui_github_link_file!()); }); ui.strong( - "This demo only works on devices with multitouch support (e.g. mobiles and tablets).", + "This demo only works on devices with multitouch support (e.g. mobiles, tablets, and trackpads).", ); ui.separator(); ui.label("Try touch gestures Pinch/Stretch, Rotation, and Pressure with 2+ fingers."); + let relative_pointer_gesture = ui.input(|i| { + i.events.iter().any(|event| { + matches!( + event, + Event::MouseWheel { .. } | Event::Zoom { .. } | Event::Rotate { .. } + ) + }) + }); let num_touches = ui.input(|i| i.multi_touch().map_or(0, |mt| mt.num_touches)); - ui.label(format!("Current touches: {num_touches}")); + let num_touches_str = format!("{num_touches}-finger touch"); + ui.label(format!( + "Input source: {}", + if ui.input(|i| i.multi_touch().is_some()) { + num_touches_str.as_str() + } else if relative_pointer_gesture { + "cursor" + } else { + "none" + } + )); let color = if ui.visuals().dark_mode { Color32::WHITE @@ -83,18 +101,18 @@ impl crate::View for MultiTouch { // check for touch input (or the lack thereof) and update zoom and scale factors, plus // color and width: let mut stroke_width = 1.; - if let Some(multi_touch) = ui.ctx().multi_touch() { - // This adjusts the current zoom factor and rotation angle according to the dynamic - // change (for the current frame) of the touch gesture: - self.zoom *= multi_touch.zoom_delta; - self.rotation += multi_touch.rotation_delta; - // the translation we get from `multi_touch` needs to be scaled down to the - // normalized coordinates we use as the basis for painting: - self.translation += to_screen.inverse().scale() * multi_touch.translation_delta; - // touch pressure will make the arrow thicker (not all touch devices support this): - stroke_width += 10. * multi_touch.force; + if ui.input(|i| i.multi_touch().is_some()) || relative_pointer_gesture { + ui.input(|input| { + // This adjusts the current zoom factor, rotation angle, and translation according + // to the dynamic change (for the current frame) of the touch gesture: + self.zoom *= input.zoom_delta(); + self.rotation += input.rotation_delta(); + self.translation += to_screen.inverse().scale() * input.translation_delta(); + // touch pressure will make the arrow thicker (not all touch devices support this): + stroke_width += 10. * input.multi_touch().map_or(0.0, |touch| touch.force); - self.last_touch_time = ui.input(|i| i.time); + self.last_touch_time = input.time; + }); } else { self.slowly_reset(ui); } diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Multi Touch.png b/crates/egui_demo_lib/tests/snapshots/demos/Multi Touch.png index e9f8a0e0..daa9da35 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Multi Touch.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Multi Touch.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9ad5938299b6fefb688ace5775f7906f5992cc119eedb487999cf963a5dcf813 -size 36275 +oid sha256:bf7f0a76424a959ede7afbb0eaf777638038cc6fe208ef710d9d82638d68b4d0 +size 37848