Add rotation gesture support for trackpad sources (#7453)

Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com>
Co-authored-by: Lucas Meurer <hi@lucasmerlin.me>
This commit is contained in:
Alan Everett 2025-09-11 06:44:17 -04:00 committed by GitHub
parent 802d307e4a
commit b0c568a78e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 174 additions and 22 deletions

View File

@ -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<egui::TouchId>,
/// 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,
}

View File

@ -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() {

View File

@ -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(),
}
}
}
}

View File

@ -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),

View File

@ -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!(

View File

@ -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);
}

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9ad5938299b6fefb688ace5775f7906f5992cc119eedb487999cf963a5dcf813
size 36275
oid sha256:bf7f0a76424a959ede7afbb0eaf777638038cc6fe208ef710d9d82638d68b4d0
size 37848