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 803FA1FF13C for ; Thu, 16 Apr 2026 11:00:25 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 462F925B47; Thu, 16 Apr 2026 11:00:25 +0200 (CEST) Message-ID: Date: Thu, 16 Apr 2026 10:59:49 +0200 MIME-Version: 1.0 User-Agent: Mozilla Thunderbird Beta Subject: superseded: [PATCH yew-widget-toolkit] touch: gesture detector: implement pinch zoom gesture From: Dominik Csapak To: yew-devel@lists.proxmox.com References: <20260415150242.3736181-1-d.csapak@proxmox.com> Content-Language: en-US In-Reply-To: <20260415150242.3736181-1-d.csapak@proxmox.com> Content-Type: text/plain; charset=UTF-8; format=flowed Content-Transfer-Encoding: 7bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1776329910747 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.101 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 SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: SUBJHVLUXOGWLCVT2T2TITQRYXAJIFBP X-Message-ID-Hash: SUBJHVLUXOGWLCVT2T2TITQRYXAJIFBP 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: superseded by v2: https://lore.proxmox.com/yew-devel/20260416085849.1062721-1-d.csapak@proxmox.com/T/#t On 4/15/26 5:02 PM, Dominik Csapak wrote: > 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};