From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id B7A2D1FF13A for ; Wed, 15 Apr 2026 17:03:17 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 93F9B18924; Wed, 15 Apr 2026 17:03:17 +0200 (CEST) From: Dominik Csapak To: yew-devel@lists.proxmox.com Subject: [PATCH yew-widget-toolkit] touch: gesture detector: implement pinch zoom gesture Date: Wed, 15 Apr 2026 17:02:33 +0200 Message-ID: <20260415150242.3736181-1-d.csapak@proxmox.com> X-Mailer: git-send-email 2.47.3 MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.102 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment POISEN_SPAM_PILL 0.1 Meta: its spam POISEN_SPAM_PILL_2 0.1 random spam to be learned in bayes POISEN_SPAM_PILL_4 0.1 random spam to be learned in bayes RCVD_IN_VALIDITY_CERTIFIED_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RCVD_IN_VALIDITY_RPBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RCVD_IN_VALIDITY_SAFE_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [mod.rs] Message-ID-Hash: PAYKAKI4WX2BA74GJUGHUAF3Q6TSA3G3 X-Message-ID-Hash: PAYKAKI4WX2BA74GJUGHUAF3Q6TSA3G3 X-MailFrom: d.csapak@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Yew framework devel list at Proxmox List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: exposes three new callbacks: * on_pinch_zoom_start * on_pinch_zoom * on_pinch_zoom_end As callback parameter a new GesturePinchZoomEvent is introduced. This struct contains the two involved points, the angle relative to the starting angle (as radians) and the relative scale of the distance between the points (for convenience). The rotation angle does consider multiple rotations, e.g. if the angle is at 359 degrees and the next update reports 1 degree, it gets updated to 361 degrees (and so on). While technically we can't know if the user rotated the touche very fast, it's rather unlikely that this happens in the real world so this heuristic seems fine. (Usually a user won't rotate their fingers more than 180-270 degrees anyway). This gesture is only registered for exactly two touches/pointers as otherwise it would be rather complicated to calculate the distance/angle. (As a further step we could expose on_multi_touch{_start,_end} then this can also be handled by a user). Signed-off-by: Dominik Csapak --- src/touch/gesture_detector.rs | 344 +++++++++++++++++++++++++++++++++- src/touch/mod.rs | 5 +- 2 files changed, 341 insertions(+), 8 deletions(-) diff --git a/src/touch/gesture_detector.rs b/src/touch/gesture_detector.rs index ec05c2b..9c8777a 100644 --- a/src/touch/gesture_detector.rs +++ b/src/touch/gesture_detector.rs @@ -86,6 +86,31 @@ impl Deref for GestureSwipeEvent { } } +/// Includes the current points involved in the gesture +pub struct GesturePinchZoomEvent { + /// First touch/pointer [Point] of the Pinch/Zoom event + pub point0: PinchPoint, + /// Second touch/pointer [Point] of the Pinch/Zoom event + pub point1: PinchPoint, + + /// Current angle of the gesture, relative to the starting position + pub angle: f64, + + /// Current scale of the distance between touch points relative to the starting positions + pub scale: f64, +} + +impl GesturePinchZoomEvent { + fn new(point0: PinchPoint, point1: PinchPoint, angle: f64, scale: f64) -> Self { + Self { + point0, + point1, + angle, + scale, + } + } +} + /// Gesture detector. /// /// You need to set the CSS attribute `touch-action: none;` on children to receive all events. @@ -96,6 +121,7 @@ impl Deref for GestureSwipeEvent { /// - long press: long tab without drag. /// - drag: pointer move while touching the surface. /// - swipe: fired at the end of a fast drag. +/// - pinch/zoom: fired when two touches/pointers move. /// /// # Note /// @@ -103,7 +129,8 @@ impl Deref for GestureSwipeEvent { /// /// Nested gesture detection is currently not implemented. /// -/// Scale and rotate detection is also not implemented. +/// It might be necessary to apply 'touch-action: none' to the content element. +/// #[derive(Properties, Clone, PartialEq)] pub struct GestureDetector { /// The yew component key. @@ -152,6 +179,18 @@ pub struct GestureDetector { #[prop_or_default] pub on_swipe: Option>, + + /// Callback for Pinch/Zoom gesture start event. + #[prop_or_default] + pub on_pinch_zoom_start: Option>, + + /// Callback for Pinch/Zoom gesture event. + #[prop_or_default] + pub on_pinch_zoom: Option>, + + /// Callback for Pinch/Zoom end gesture event. + #[prop_or_default] + pub on_pinch_zoom_end: Option>, } impl GestureDetector { @@ -203,6 +242,27 @@ impl GestureDetector { self.on_swipe = cb.into_event_callback(); self } + + /// Builder style method to set the on_pinch_zoom_start callback + pub fn on_pinch_zoom_start( + mut self, + cb: impl IntoEventCallback, + ) -> Self { + self.on_pinch_zoom_start = cb.into_event_callback(); + self + } + + /// Builder style method to set the on_pinch_zoom callback + pub fn on_pinch_zoom(mut self, cb: impl IntoEventCallback) -> Self { + self.on_pinch_zoom = cb.into_event_callback(); + self + } + + /// Builder style method to set the on_pinch_zoom_end callback + pub fn on_pinch_zoom_end(mut self, cb: impl IntoEventCallback) -> Self { + self.on_pinch_zoom_end = cb.into_event_callback(); + self + } } pub enum Msg { @@ -227,6 +287,7 @@ enum DetectionState { Single, Drag, Double, + Multi, // Error, Done, } @@ -245,12 +306,86 @@ struct PointerState { direction: f64, } +impl PointerState { + fn to_pinch_point(&self, id: i32) -> PinchPoint { + PinchPoint { + id, + x: self.x, + y: self.y, + } + } +} + +/// Represents a single pointer or touch +pub struct PinchPoint { + pub id: i32, + pub x: i32, + pub y: i32, +} + +impl PinchPoint { + /// calculates the distance in pixels to another [Point] + pub fn distance(&self, other: &PinchPoint) -> f64 { + compute_distance(self.x, self.y, other.x, other.y) + } + + /// calculates the angle of the line to another [Point] in radians + pub fn angle(&self, other: &PinchPoint) -> f64 { + let x_diff = (other.x - self.x) as f64; + let y_diff = (-other.y + self.y) as f64; + + y_diff.atan2(x_diff) + std::f64::consts::PI + } +} + +#[derive(Debug, Default, Clone, PartialEq)] +struct PinchZoomInfo { + start_angle: f64, + current_angle: f64, + start_distance: f64, + current_distance: f64, +} + +impl PinchZoomInfo { + fn new(point0: PinchPoint, point1: PinchPoint) -> Self { + let angle = point0.angle(&point1); + + // force a minimal distance of 1 pixel + let distance = point0.distance(&point1).max(1.0); + + Self { + start_angle: angle, + current_angle: angle, + start_distance: distance, + current_distance: distance, + } + } + + fn update(&mut self, point0: PinchPoint, point1: PinchPoint) { + let last_angle = self.current_angle; + let rotations = (last_angle / std::f64::consts::TAU).round(); + + let angle = point0.angle(&point1) + rotations * std::f64::consts::TAU; + + if (last_angle - angle).abs() < std::f64::consts::PI { + self.current_angle = angle; + } else if last_angle > angle { + self.current_angle = angle + std::f64::consts::TAU; + } else if last_angle < angle { + self.current_angle = angle - std::f64::consts::TAU; + } + + self.current_distance = point0.distance(&point1); + } +} + #[doc(hidden)] pub struct PwtGestureDetector { touch_only: bool, node_ref: NodeRef, state: DetectionState, pointers: HashMap, + pinch_zoom_info: PinchZoomInfo, } fn now() -> f64 { @@ -298,6 +433,10 @@ impl PwtGestureDetector { let start_y = event.y(); self.register_pointer_state(ctx, id, start_x, start_y); + + if self.pointers.len() == 2 { + self.start_pinch_zoom(); + } } fn register_touches(&mut self, ctx: &Context, event: &TouchEvent) { @@ -307,6 +446,20 @@ impl PwtGestureDetector { let y = touch.client_y(); self.register_pointer_state(ctx, id, x, y); }); + + if self.pointers.len() == 2 { + self.start_pinch_zoom(); + } + } + + fn start_pinch_zoom(&mut self) { + let (point0, point1) = self.get_pinch_points(); + self.pinch_zoom_info = PinchZoomInfo::new(point0, point1) + } + + fn update_pinch_zoom(&mut self) { + let (point0, point1) = self.get_pinch_points(); + self.pinch_zoom_info.update(point0, point1); } fn unregister_touches( @@ -332,6 +485,28 @@ impl PwtGestureDetector { } } + fn get_pinch_points(&self) -> (PinchPoint, PinchPoint) { + let mut points: Vec<_> = self + .pointers + .iter() + .map(|(id, pointer)| pointer.to_pinch_point(*id)) + .collect(); + assert!(points.len() == 2); + + // sort for stable stable order + points.sort_by_key(|p| p.id); + + (points.remove(0), points.remove(0)) + } + + fn get_angle(&self) -> f64 { + self.pinch_zoom_info.current_angle - self.pinch_zoom_info.start_angle + } + + fn get_scale(&self) -> f64 { + self.pinch_zoom_info.current_distance / self.pinch_zoom_info.start_distance + } + fn update_pointer_position(&mut self, id: i32, x: i32, y: i32) -> Option<&PointerState> { if let Some(pointer_state) = self.pointers.get_mut(&id) { let ctime = now(); @@ -382,8 +557,12 @@ impl PwtGestureDetector { self.state = match self.pointers.len() { 0 => DetectionState::Initial, 1 => DetectionState::Single, - // TODO implement more touches - _ => DetectionState::Double, + 2 => { + let (point0, point1) = self.get_pinch_points(); + self.call_on_pinch_zoom_start(ctx, point0, point1); + DetectionState::Double + } + _ => DetectionState::Multi, }; } Msg::PointerUp(_event) => { /* ignore */ } @@ -432,6 +611,8 @@ impl PwtGestureDetector { assert!(pointer_count == 1); self.register_pointer(ctx, &event); self.state = DetectionState::Double; + let (point0, point1) = self.get_pinch_points(); + self.call_on_pinch_zoom_start(ctx, point0, point1); } Msg::TouchStart(event) => { let pointer_count = self.pointers.len(); @@ -440,8 +621,12 @@ impl PwtGestureDetector { self.state = match self.pointers.len() { 0 => DetectionState::Initial, 1 => DetectionState::Single, - // TODO implement more touches - _ => DetectionState::Double, + 2 => { + let (point0, point1) = self.get_pinch_points(); + self.call_on_pinch_zoom_start(ctx, point0, point1); + DetectionState::Double + } + _ => DetectionState::Multi, }; } Msg::PointerUp(event) => { @@ -560,13 +745,27 @@ impl PwtGestureDetector { if let Some(on_drag_end) = &props.on_drag_end { on_drag_end.emit(event.into()); } + let (point0, point1) = self.get_pinch_points(); + self.call_on_pinch_zoom_start(ctx, point0, point1); } Msg::TouchStart(event) => { let pointer_count = self.pointers.len(); assert!(pointer_count == 1); // Abort current drags self.register_touches(ctx, &event); - self.state = DetectionState::Double; + let pointer_count = self.pointers.len(); + match pointer_count { + 2 => { + self.state = DetectionState::Double; + + let (point0, point1) = self.get_pinch_points(); + self.call_on_pinch_zoom_start(ctx, point0, point1); + } + count if count > 2 => { + self.state = DetectionState::Multi; + } + _ => {} + } for_each_active_touch(&event, |touch| { if self.pointers.contains_key(&touch.identifier()) { if let Some(on_drag_end) = &props.on_drag_end { @@ -717,6 +916,135 @@ impl PwtGestureDetector { true } + fn call_on_pinch_zoom_start( + &mut self, + ctx: &Context, + point0: PinchPoint, + point1: PinchPoint, + ) { + if let Some(on_pinch_zoom_start) = &ctx.props().on_pinch_zoom_start { + on_pinch_zoom_start.emit(GesturePinchZoomEvent::new( + point0, + point1, + self.get_angle(), + self.get_scale(), + )) + } + } + + fn call_on_pinch_zoom(&mut self, ctx: &Context, point0: PinchPoint, point1: PinchPoint) { + if let Some(on_pinch_zoom) = &ctx.props().on_pinch_zoom { + on_pinch_zoom.emit(GesturePinchZoomEvent::new( + point0, + point1, + self.get_angle(), + self.get_scale(), + )) + } + } + + fn call_on_pinch_zoom_end( + &mut self, + ctx: &Context, + point0: PinchPoint, + point1: PinchPoint, + ) { + if let Some(on_pinch_zoom_end) = &ctx.props().on_pinch_zoom_end { + on_pinch_zoom_end.emit(GesturePinchZoomEvent::new( + point0, + point1, + self.get_angle(), + self.get_scale(), + )) + } + } + + fn update_double(&mut self, ctx: &Context, msg: Msg) -> bool { + match msg { + Msg::TapTimeout(_id) => { /* ignore */ } + Msg::LongPressTimeout(_id) => { /* ignore */ } + Msg::PointerDown(event) => { + let pointer_count = self.pointers.len(); + assert!(pointer_count == 2); + self.register_pointer(ctx, &event); + self.state = DetectionState::Multi; + } + Msg::TouchStart(event) => { + let pointer_count = self.pointers.len(); + assert!(pointer_count == 2); + let (point0, point1) = self.get_pinch_points(); + self.register_touches(ctx, &event); + self.state = DetectionState::Multi; + self.call_on_pinch_zoom_end(ctx, point0, point1); + } + Msg::PointerUp(event) | Msg::PointerCancel(event) | Msg::PointerLeave(event) => { + event.prevent_default(); + let pointer_count = self.pointers.len(); + assert!(pointer_count == 2); + let (point0, point1) = self.get_pinch_points(); + if self.unregister_pointer(event.pointer_id()).is_some() { + self.state = DetectionState::Drag; + } + self.call_on_pinch_zoom_end(ctx, point0, point1); + } + Msg::TouchEnd(event) | Msg::TouchCancel(event) => { + let pointer_count = self.pointers.len(); + assert!(pointer_count == 2); + let (point0, point1) = self.get_pinch_points(); + let mut unregistered = 0; + for_each_changed_touch(&event, |touch| { + if self.unregister_pointer(touch.identifier()).is_some() { + unregistered += 1; + } + }); + let pointer_count = pointer_count.saturating_sub(unregistered); + if pointer_count < 2 { + self.call_on_pinch_zoom_end(ctx, point0, point1); + } + match pointer_count { + 0 => self.state = DetectionState::Initial, + 1 => self.state = DetectionState::Drag, + 2 => {} + _more => self.state = DetectionState::Multi, // more touchpoints on removal? + } + } + Msg::PointerMove(event) => { + event.prevent_default(); + let updated = self + .update_pointer_position(event.pointer_id(), event.x(), event.y()) + .is_some(); + + self.update_pinch_zoom(); + + if updated { + let (point0, point1) = self.get_pinch_points(); + self.call_on_pinch_zoom(ctx, point0, point1); + } + } + Msg::TouchMove(event) => { + let mut had_valid = false; + for_each_changed_touch(&event, |touch| { + if self + .update_pointer_position( + touch.identifier(), + touch.client_x(), + touch.client_y(), + ) + .is_some() + { + had_valid = true + } + }); + self.update_pinch_zoom(); + if had_valid { + let (point0, point1) = self.get_pinch_points(); + self.call_on_pinch_zoom(ctx, point0, point1); + } + } + } + true + } + // Wait until all pointers are released fn update_error(&mut self, ctx: &Context, msg: Msg) -> bool { match msg { @@ -777,6 +1105,7 @@ impl Component for PwtGestureDetector { state: DetectionState::Initial, pointers: HashMap::new(), node_ref: NodeRef::default(), + pinch_zoom_info: PinchZoomInfo::default(), } } fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { @@ -786,7 +1115,8 @@ impl Component for PwtGestureDetector { DetectionState::Initial => self.update_initial(ctx, msg), DetectionState::Single => self.update_single(ctx, msg), DetectionState::Drag => self.update_drag(ctx, msg), - DetectionState::Double => self.update_error(ctx, msg), // todo + DetectionState::Double => self.update_double(ctx, msg), + DetectionState::Multi => self.update_error(ctx, msg), // todo //DetectionState::Error => self.update_error(ctx, msg), DetectionState::Done => self.update_error(ctx, msg), } diff --git a/src/touch/mod.rs b/src/touch/mod.rs index acb27ae..e9a8e19 100644 --- a/src/touch/mod.rs +++ b/src/touch/mod.rs @@ -5,7 +5,10 @@ mod application_bar; pub use application_bar::{ApplicationBar, PwtApplicationBar}; mod gesture_detector; -pub use gesture_detector::{GestureDetector, GestureSwipeEvent, InputEvent, PwtGestureDetector}; +pub use gesture_detector::{ + GestureDetector, GesturePinchZoomEvent, GestureSwipeEvent, InputEvent, PinchPoint, + PwtGestureDetector, +}; mod fab; pub use fab::{Fab, FabSize, PwtFab}; -- 2.47.3