public inbox for yew-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Dominik Csapak <d.csapak@proxmox.com>
To: yew-devel@lists.proxmox.com
Subject: superseded: [PATCH yew-widget-toolkit] touch: gesture detector: implement pinch zoom gesture
Date: Thu, 16 Apr 2026 10:59:49 +0200	[thread overview]
Message-ID: <a7873379-2629-43c9-b0b7-e5ad034b2bce@proxmox.com> (raw)
In-Reply-To: <20260415150242.3736181-1-d.csapak@proxmox.com>

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 <d.csapak@proxmox.com>
> ---
>   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<GestureSwipeEvent>>,
> +
> +    /// Callback for Pinch/Zoom gesture start event.
> +    #[prop_or_default]
> +    pub on_pinch_zoom_start: Option<Callback<GesturePinchZoomEvent>>,
> +
> +    /// Callback for Pinch/Zoom gesture event.
> +    #[prop_or_default]
> +    pub on_pinch_zoom: Option<Callback<GesturePinchZoomEvent>>,
> +
> +    /// Callback for Pinch/Zoom end gesture event.
> +    #[prop_or_default]
> +    pub on_pinch_zoom_end: Option<Callback<GesturePinchZoomEvent>>,
>   }
>   
>   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<GesturePinchZoomEvent>,
> +    ) -> 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<GesturePinchZoomEvent>) -> 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<GesturePinchZoomEvent>) -> 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<i32, PointerState>,
> +    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<Self>, 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<F: FnMut(i32, Touch, PointerState)>(
> @@ -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<Self>,
> +        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<Self>, 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<Self>,
> +        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<Self>, 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<Self>, 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<Self>, 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};





      parent reply	other threads:[~2026-04-16  9:00 UTC|newest]

Thread overview: 4+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-04-15 15:02 Dominik Csapak
2026-04-15 16:04 ` Dietmar Maurer
2026-04-16  6:30   ` Dominik Csapak
2026-04-16  8:59 ` Dominik Csapak [this message]

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=a7873379-2629-43c9-b0b7-e5ad034b2bce@proxmox.com \
    --to=d.csapak@proxmox.com \
    --cc=yew-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal