// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! Per-surface gesture state machine. Source-agnostic — both //! `PointerHandler` (Wayland `wl_pointer`) and `TouchHandler` //! (`wl_touch`) feed into the same machine and apply the decisions it //! emits. //! //! ## Lifecycle //! //! 1. [`GestureState::on_press`] — records the gesture origin, snapshots //! the hit widget's long-press AND drag-start handlers, identifies //! slider / scroll targets. Returns a [`PressOutcome`] with the hit //! index and any initial slider message. //! 2. [`GestureState::on_move`] — cancels the long-press candidate if //! the finger strays past 6 px, updates slider values, mutates //! scroll offsets, computes swipe progress. Returns a //! [`MoveOutcome`] telling the handler which app callbacks to fire. //! 3. [`GestureState::on_release`] — decides between drop (long-press //! was already fired), slider / scroll done, horizontal / vertical //! swipe commit or fall-through, tap or button press. Returns an //! ordered [`Vec`] — a single release can emit more //! than one event when a horizontal swipe falls through and the //! handler still has to check the vertical branch. //! 4. [`GestureState::on_cancel`] — resets everything (touch-cancel //! only; the compositor is stealing the gesture). //! //! The long-press DEADLINE itself is not polled here. `AppData` //! walks every surface's `gesture.long_press_start` against the app's //! `long_press_duration()` and flips `gesture.long_press_fired` when //! the deadline elapses — this module only records the start instant //! and reacts to the flip. use std::collections::HashMap; use std::time::Instant; use crate::tree::{ find_handlers, find_widget, find_widget_at }; use crate::types::{ Point, Rect }; use crate::widget::LaidOutWidget; // ─── State ─────────────────────────────────────────────────────────────────── /// Per-surface gesture tracking. Lives on `SurfaceState::gesture`. /// /// Every field is `pub` because `AppData`'s long-press deadline /// checker in `event_loop/app_data.rs` needs to read the start instant /// and set `long_press_fired` from outside the state machine. The /// lifecycle methods ([`Self::on_press`], [`Self::on_move`], /// [`Self::on_release`], [`Self::on_cancel`]) are the supported way /// for input handlers to drive the machine; direct mutation is only /// for the deadline path. #[ derive( Debug, Clone ) ] pub struct GestureState { /// Position where the current press / touch-down landed. `None` /// between gestures. pub start: Option, /// Widget index under the press, if any. Set on press; taken on /// release. pub pressed_idx: Option, /// Index of the Scroll viewport that owns the current gesture, if /// the press landed inside one. pub scrolling_widget: Option, /// Scroll-viewport drag exceeded the 8 px start tolerance — the /// release will be consumed as a scroll instead of a tap. pub scroll_drag_started: bool, /// Horizontal drag exceeded the 8 px threshold. Release runs the /// horizontal-swipe commit check instead of the button path. pub horizontal_drag_started: bool, /// Vertical drag exceeded the 8 px threshold. Release runs the /// swipe-up / swipe-down commit check instead of the button path. pub vertical_drag_started: bool, /// Slider widget being dragged for a live-value update. pub dragging_slider: Option, /// Instant when the long-press window opened. `None` if the hit /// widget has no long-press handler (or the candidate was /// cancelled by movement). pub long_press_start: Option, /// Original press position for the long-press candidate — used by /// [`Self::on_move`] to cancel if the finger strays more than 6 px. pub long_press_origin: Option, /// Message to fire when the deadline elapses (touch hold) or when /// the user right-clicks. Drained by the deadline checker on touch /// and by the right-click handler on mouse. Firing this on its own /// does NOT put the gesture into drag mode — that is governed by /// [`Self::drag_start_msg`]. pub long_press_msg: Option, /// Drag-arm message captured from the hit widget on press. Fired /// by the touch deadline alongside `long_press_msg` and by the /// mouse-motion drag-promotion path on its own. When fired, the /// gesture transitions into drag mode (`long_press_fired = true`) /// and subsequent motion / release run through `on_drag_move` / /// `on_drop`. pub drag_start_msg: Option, /// `widget_rects` index of a TextEdit the press landed on, when /// no user `on_long_press` is configured. Lets the runtime show /// the built-in Copy / Cut / Paste menu after the same delay /// without forcing every text field to opt in. pub long_press_text_idx: Option, /// `true` once the gesture has transitioned into drag mode — /// subsequent motion fires `app.on_drag_move`, release fires /// `app.on_drop`. Set by the touch deadline when `drag_start_msg` /// is present, or by the mouse-motion promotion path. pub long_press_fired: bool, /// `true` when the press came from a mouse (`wl_pointer`) rather /// than touch. The 6 px stray-cancel in [`Self::on_move`] is gated /// on this: touch needs the cancel so a hold that turns into a /// swipe / scroll doesn't keep the long-press candidate armed, /// but mouse motion is always intentional and never competes with /// a swipe gesture, so the candidate stays alive until the /// pointer-side promotion threshold or the deadline fires. Set by /// the pointer Press handler right after [`Self::on_press`]; left /// `false` for touch presses. pub mouse_press: bool, } impl Default for GestureState { fn default() -> Self { Self::new() } } impl GestureState { /// Fresh state — no press in flight. pub fn new() -> Self { Self { start: None, pressed_idx: None, scrolling_widget: None, scroll_drag_started: false, horizontal_drag_started: false, vertical_drag_started: false, dragging_slider: None, long_press_start: None, long_press_origin: None, long_press_msg: None, drag_start_msg: None, long_press_text_idx: None, long_press_fired: false, mouse_press: false, } } /// Press / touch-down: record the gesture origin, capture long-press /// handler if the hit widget has one, identify slider / scroll /// targets. Returns the hit index (for keyboard focus update) and /// any initial slider message (the slider applies its value /// immediately on press so the thumb tracks the finger from pixel /// one). pub fn on_press ( &mut self, pos: Point, widget_rects: &[LaidOutWidget], scroll_rects: &[( Rect, usize )], ) -> PressOutcome { let hit = find_widget_at( widget_rects, pos ); let ( lp_msg, ds_msg ) = hit.map( |idx| { let h = find_handlers( widget_rects, idx ); ( h.as_ref().and_then( |h| h.long_press_msg() ), h.as_ref().and_then( |h| h.drag_start_msg() ), ) }).unwrap_or( ( None, None ) ); self.start = Some( pos ); self.scrolling_widget = scroll_rects.iter().rev() .find( |( r, _ )| r.contains( pos ) ) .map( |( _, idx )| *idx ); self.scroll_drag_started = false; self.horizontal_drag_started = false; self.vertical_drag_started = false; self.pressed_idx = hit; // A fresh press resets any stale long-press / source state // from a prior gesture that never released cleanly. The // pointer Press handler flips `mouse_press` back to true // right after this call when the press came from a mouse; // touch leaves it `false`. self.long_press_fired = false; self.mouse_press = false; // Built-in long-press for text inputs: arms the same timer // even when the widget has no user-set `on_long_press`, so // the runtime can show the Copy / Cut / Paste menu after the // usual delay. let text_idx = hit.and_then( |idx| { find_handlers( widget_rects, idx ) .and_then( |h| if h.is_text_input() { Some( idx ) } else { None } ) } ); // Arm the long-press timer if anything is pending: the menu // message, the drag-start message, or the built-in text menu. // A widget that only carries `on_drag_start` (no menu) still // needs the timer so a touch hold can promote into a drag. let arm_long_press = lp_msg.is_some() || ds_msg.is_some() || text_idx.is_some(); self.long_press_start = arm_long_press.then( Instant::now ); self.long_press_origin = arm_long_press.then_some( pos ); self.long_press_msg = lp_msg; self.drag_start_msg = ds_msg; self.long_press_text_idx = text_idx; // Slider drag: if the hit widget is a slider, arm the drag and // apply the press-point value immediately. let mut initial_slider_msg = None; if let Some( idx ) = hit { if let Some( w ) = find_widget( widget_rects, idx ) { if w.handlers.is_slider() { self.dragging_slider = Some( idx ); let value = w.handlers.slider_value_from_pos( w.rect, pos ); initial_slider_msg = w.handlers.slider_change_msg( value ); } } } PressOutcome { hit_idx: hit, initial_slider_msg } } /// Move / touch-motion: classify the motion and mutate scroll /// offsets in place. `global_drag` is `true` when another surface /// has an active long-press drag (cross-surface drags route every /// motion through `on_drag_move` even on surfaces that didn't /// start the gesture). pub fn on_move ( &mut self, pos: Point, widget_rects: &[LaidOutWidget], scroll_offsets: &mut HashMap, swipe: &SwipeConfig, global_drag: bool, ) -> MoveOutcome { // Drag phase: once the long-press has fired, every motion // goes to the app's drag handler and bypasses slider / scroll / // swipe logic. if self.long_press_fired || global_drag { return MoveOutcome::Drag { pos }; } // Cancel the long-press candidate if the finger strayed > 6 px // — but ONLY for touch. Touch needs this so a hold that turns // into a swipe / scroll doesn't keep the menu candidate // armed and fire mid-swipe; mouse motion is always // intentional and never competes with a swipe, so the // candidate stays alive until the pointer-side promotion at // 24 px (or the hold-timer deadline) consumes it. Without // this gate, mouse motion in the 6–24 px band would clear // `drag_start_msg` before promotion could fire and the user // would have to wait out the full 500 ms hold timer to drag. if !self.mouse_press { if let Some( origin ) = self.long_press_origin { let d = ( pos.x - origin.x ).hypot( pos.y - origin.y ); if d > 6.0 { self.long_press_start = None; self.long_press_origin = None; self.long_press_msg = None; self.drag_start_msg = None; self.long_press_text_idx = None; } } } // Slider drag: continuously update value. if let Some( slider_idx ) = self.dragging_slider { if let Some( w ) = find_widget( widget_rects, slider_idx ) { let value = w.handlers.slider_value_from_pos( w.rect, pos ); let msg = w.handlers.slider_change_msg( value ); return MoveOutcome::Slider { msg }; } return MoveOutcome::Idle; } // Scroll viewport drag: mutate offset in place and advance the // gesture origin so the next delta is frame-to-frame, not // press-to-now (otherwise the first 8 px trip the threshold // and the entire scroll gets absorbed into one delta). if let Some( scroll_idx ) = self.scrolling_widget { if let Some( start ) = self.start { let dy = pos.y - start.y; let entry = scroll_offsets.entry( scroll_idx ).or_insert( 0.0 ); *entry = ( *entry - dy ).max( 0.0 ); if dy.abs() > 8.0 { self.scroll_drag_started = true; } self.start = Some( pos ); return MoveOutcome::Scroll; } return MoveOutcome::Idle; } // Swipe progress — independent on both axes so a vertical- // dominant gesture that picks up horizontal drift still drives // the pager. if let Some( start ) = self.start { let dy = start.y - pos.y; let dx = pos.x - start.x; let ( up, down ) = if swipe.surface_height > 0 { let h = swipe.surface_height as f32; if dy > 0.0 { let threshold = h * swipe.up_thresh; if dy.abs() > 8.0 { self.vertical_drag_started = true; } ( Some( ( dy / threshold ).clamp( 0.0, 1.0 ) ), None ) } else if start.y <= h * swipe.down_edge { let threshold = h * swipe.down_thresh; if dy.abs() > 8.0 { self.vertical_drag_started = true; } // Unclamped on purpose — lets follow-the-finger // panels track past the commit threshold. ( None, Some( ( -dy / threshold ).max( 0.0 ) ) ) } else { ( None, None ) } } else { ( None, None ) }; let horizontal = if swipe.surface_width > 0 { let h_thr = swipe.surface_width as f32 * swipe.horizontal_thresh; let h_progress = if h_thr > 0.0 { dx / h_thr } else { 0.0 }; if dx.abs() > 8.0 { self.horizontal_drag_started = true; } Some( h_progress ) } else { None }; if up.is_some() || down.is_some() || horizontal.is_some() { return MoveOutcome::Swipe { up, down, horizontal }; } } MoveOutcome::Idle } /// Release / touch-up: decide between drop / slider done / scroll /// done / swipe commit / swipe fall-through / button press / tap. /// /// A release can emit multiple events: a horizontal swipe that /// didn't commit still fires `on_swipe_horizontal_progress(0.0)` /// before falling through to the vertical branch, and a below- /// threshold vertical swipe pulses the vertical progress /// callbacks. The handler dispatches the events in order. /// /// `global_drag` mirrors the parameter of [`Self::on_move`]. pub fn on_release ( &mut self, pos: Point, widget_rects: &[LaidOutWidget], swipe: &SwipeConfig, global_drag: bool, ) -> Vec> { let pressed = self.pressed_idx.take(); let was_dragging_slider = self.dragging_slider.is_some(); let long_press_fired = self.long_press_fired; let horizontal_drag_started = self.horizontal_drag_started; let vertical_drag_started = self.vertical_drag_started; // Drain all the drag / long-press slots. A release ALWAYS // closes the long-press window: if the deadline fired we've // already consumed the msg; if not, the candidate is cancelled. self.dragging_slider = None; self.long_press_start = None; self.long_press_origin = None; self.long_press_msg = None; self.drag_start_msg = None; self.long_press_text_idx = None; self.long_press_fired = false; self.mouse_press = false; self.horizontal_drag_started = false; self.vertical_drag_started = false; let mut events = Vec::new(); // Drop — long-press had fired earlier in the gesture. if long_press_fired || global_drag { self.start = None; events.push( ReleaseEvent::Drop { pos } ); return events; } // Slider drag complete — not a swipe, not a tap. if was_dragging_slider { self.scroll_drag_started = false; self.start = None; return events; } // Scroll gesture consumed. let scroll_consumed = self.scrolling_widget.take().is_some() && self.scroll_drag_started; self.scroll_drag_started = false; if scroll_consumed { self.start = None; return events; } // Horizontal swipe commit / fall-through. On commit we return // immediately; on fall-through we send the 0.0 pulse, keep // `self.start` for the vertical branch, and continue. if horizontal_drag_started { if let Some( start ) = self.start { let dx = pos.x - start.x; let width = swipe.surface_width; let committed = width > 0 && dx.abs() >= width as f32 * swipe.horizontal_thresh; if committed { self.start = None; events.push( if dx < 0.0 { ReleaseEvent::SwipeLeft } else { ReleaseEvent::SwipeRight } ); return events; } events.push( ReleaseEvent::HorizontalFellThrough ); } } // Vertical swipe commit / fall-through. Consumes `self.start`. if vertical_drag_started || pressed.is_none() { if let Some( start ) = self.start.take() { let dy = start.y - pos.y; let height = swipe.surface_height as f32; let up_thr = height * swipe.up_thresh; let down_thr = height * swipe.down_thresh; let down_edge = height * swipe.down_edge; if dy >= up_thr { events.push( ReleaseEvent::SwipeUp ); return events; } else if -dy >= down_thr && start.y <= down_edge { events.push( ReleaseEvent::SwipeDown ); return events; } else { events.push( ReleaseEvent::VerticalFellThrough ); } } } else { self.start = None; } // Any drag started — skip the button path so a gesture that // fell through without committing does not also trigger a tap. if horizontal_drag_started || vertical_drag_started { return events; } // Button / slider press on release: the release must land on // the same widget the press did. let release_hit = find_widget_at( widget_rects, pos ); if let Some( idx ) = pressed { if release_hit == Some( idx ) { if let Some( w ) = find_widget( widget_rects, idx ) { let value = w.handlers.slider_value_from_pos( w.rect, pos ); if let Some( msg ) = w.handlers.slider_change_msg( value ) { events.push( ReleaseEvent::PushMsg( msg ) ); return events; } // Repeating buttons fire `press_msg` on *press* (and // keep firing through the calloop repeat timer // while held) — suppressing the release-time fire // avoids a double tap on a quick click. if !w.handlers.is_repeating() { if let Some( msg ) = w.handlers.press_msg() { events.push( ReleaseEvent::PushMsg( msg ) ); } } } } } else if release_hit.is_none() { // Release on empty area — handler decides Tap vs // overlay-dismiss based on focus. events.push( ReleaseEvent::EmptyRelease ); } events } /// Touch-cancel: drop all in-flight gesture state. pub fn on_cancel( &mut self ) { *self = Self::new(); } } // ─── Outcomes ──────────────────────────────────────────────────────────────── /// Result of [`GestureState::on_press`]. Handler uses `hit_idx` to set /// keyboard focus; pushes `initial_slider_msg` to the pending queue /// when present. pub struct PressOutcome { pub hit_idx: Option, pub initial_slider_msg: Option, } /// Result of [`GestureState::on_move`]. Handler turns this into app /// callbacks + redraw flags. pub enum MoveOutcome { /// Nothing gesture-level fired. Hovered index may still have /// changed — pointer handler updates it separately before calling /// on_move. Idle, /// Long-press drag in flight — fire `app.on_drag_move(pos)`. Drag { pos: Point }, /// Slider value changed. `None` when the slider has no change /// handler for this value (typically unreachable — sliders always /// carry a change msg — but the gesture machine stays general). Slider { msg: Option }, /// Scroll viewport offset updated. Handler just requests a redraw. Scroll, /// Swipe progress. Each axis is `Some(value)` when it has a valid /// progress reading for this move (not every motion updates all /// axes). Handler fires the matching app callback. Swipe { up: Option, down: Option, horizontal: Option }, } /// Events emitted by [`GestureState::on_release`], in the order the /// handler should dispatch them. Multiple events per release are /// possible (see the horizontal fall-through case). pub enum ReleaseEvent { /// Long-press drop — `app.on_drop(pos)`, kick main redraw, clear /// long-press drag. Drop { pos: Point }, /// Horizontal swipe committed left. SwipeLeft, /// Horizontal swipe committed right. SwipeRight, /// Vertical swipe committed up. SwipeUp, /// Vertical swipe committed down. SwipeDown, /// Horizontal swipe did not commit — fire /// `app.on_swipe_horizontal_progress(0.0)` + main redraw. HorizontalFellThrough, /// Vertical swipe did not commit — fire /// `app.on_swipe_progress(0.0)` + `app.on_swipe_down_progress(0.0)`. VerticalFellThrough, /// Push a widget-level message (button press or final slider /// value on release). PushMsg( Msg ), /// Release on empty area — handler calls `app.on_tap()` on Main /// focus or `overlay_dismiss_msg(id)` on Overlay focus. EmptyRelease, } /// Swipe thresholds + surface dimensions snapshotted from the app + /// current surface, passed into gesture methods so the machine stays /// decoupled from `App` and `SurfaceState`. pub struct SwipeConfig { /// Up-swipe commit fraction of surface height (e.g. 0.3 = 30 %). pub up_thresh: f32, /// Down-swipe commit fraction of surface height. pub down_thresh: f32, /// Down-swipe start zone fraction — the press must land within /// `surface_height * down_edge` of the top for a down-swipe to /// arm. pub down_edge: f32, /// Horizontal commit fraction of surface width. pub horizontal_thresh: f32, pub surface_width: u32, pub surface_height: u32, } #[ cfg( test ) ] mod tests { use super::*; use crate::widget::{ LaidOutWidget, WidgetHandlers }; #[ derive( Clone, Debug, PartialEq, Eq ) ] enum Msg { Pressed, LongPressed, } fn rect( x: f32, y: f32, w: f32, h: f32 ) -> Rect { Rect { x, y, width: w, height: h } } fn pt( x: f32, y: f32 ) -> Point { Point { x, y } } fn button( idx: usize, r: Rect, on_press: Option, on_long_press: Option ) -> LaidOutWidget { button_full( idx, r, on_press, on_long_press, None ) } fn button_full( idx: usize, r: Rect, on_press: Option, on_long_press: Option, on_drag_start: Option, ) -> LaidOutWidget { LaidOutWidget { rect: r, flat_idx: idx, id: None, paint_rect: r, handlers: WidgetHandlers::Button { on_press, on_long_press, on_drag_start, on_escape: None, repeating: false }, keyboard_focusable: true, cursor: crate::types::CursorShape::Default, tooltip: None, } } fn cfg_full( w: u32, h: u32 ) -> SwipeConfig { SwipeConfig { up_thresh: 0.30, down_thresh: 0.30, down_edge: 0.10, horizontal_thresh: 0.30, surface_width: w, surface_height: h, } } // ── on_press ────────────────────────────────────────────────────────────── #[ test ] fn press_records_origin_and_hit_idx() { let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), Some( Msg::Pressed ), None ) ]; let mut g = GestureState::::new(); let out = g.on_press( pt( 50.0, 50.0 ), &widgets, &[] ); assert_eq!( out.hit_idx, Some( 1 ) ); assert!( out.initial_slider_msg.is_none() ); assert_eq!( g.start, Some( pt( 50.0, 50.0 ) ) ); assert_eq!( g.pressed_idx, Some( 1 ) ); } #[ test ] fn press_outside_any_widget_records_origin_with_no_hit() { let widgets = vec![ button( 1, rect( 0.0, 0.0, 50.0, 50.0 ), Some( Msg::Pressed ), None ) ]; let mut g = GestureState::::new(); let out = g.on_press( pt( 200.0, 200.0 ), &widgets, &[] ); assert_eq!( out.hit_idx, None ); assert_eq!( g.start, Some( pt( 200.0, 200.0 ) ) ); assert!( g.long_press_start.is_none() ); } #[ test ] fn press_arms_long_press_when_handler_is_present() { let widgets = vec![ button( 7, rect( 0.0, 0.0, 100.0, 100.0 ), None, Some( Msg::LongPressed ) ) ]; let mut g = GestureState::::new(); let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] ); assert!( g.long_press_start.is_some() ); assert_eq!( g.long_press_msg, Some( Msg::LongPressed ) ); assert_eq!( g.long_press_origin, Some( pt( 10.0, 10.0 ) ) ); assert!( !g.long_press_fired ); } #[ test ] fn press_does_not_arm_long_press_without_handler() { let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), Some( Msg::Pressed ), None ) ]; let mut g = GestureState::::new(); let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] ); assert!( g.long_press_start.is_none() ); assert!( g.long_press_msg.is_none() ); assert!( g.drag_start_msg.is_none() ); } #[ test ] fn press_arms_long_press_when_only_drag_start_is_set() { // A draggable-but-menu-less widget (e.g. launcher icons) still // needs the timer so a touch hold can promote into a drag. let widgets = vec![ button_full( 3, rect( 0.0, 0.0, 100.0, 100.0 ), None, None, Some( Msg::Pressed ) ) ]; let mut g = GestureState::::new(); let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] ); assert!( g.long_press_start.is_some() ); assert!( g.long_press_msg.is_none() ); assert_eq!( g.drag_start_msg, Some( Msg::Pressed ) ); } #[ test ] fn move_past_six_pixels_cancels_drag_start_msg_too() { let widgets = vec![ button_full( 4, rect( 0.0, 0.0, 200.0, 200.0 ), None, Some( Msg::LongPressed ), Some( Msg::Pressed ) ) ]; let mut g = GestureState::::new(); let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] ); let mut offsets = HashMap::new(); let _ = g.on_move( pt( 30.0, 30.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false ); assert!( g.long_press_msg.is_none() ); assert!( g.drag_start_msg.is_none() ); } #[ test ] fn mouse_press_skips_six_pixel_cancel_so_promotion_can_fire() { // With `mouse_press = true` the gesture state must keep the // drag-start / long-press slots alive past the 6 px touch // tolerance — otherwise the pointer-side promotion at 24 px // would never see them and a mouse drag past the cancel // distance would do nothing until the hold-timer deadline. let widgets = vec![ button_full( 5, rect( 0.0, 0.0, 200.0, 200.0 ), None, Some( Msg::LongPressed ), Some( Msg::Pressed ) ) ]; let mut g = GestureState::::new(); let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] ); g.mouse_press = true; let mut offsets = HashMap::new(); let _ = g.on_move( pt( 30.0, 30.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false ); assert_eq!( g.long_press_msg, Some( Msg::LongPressed ) ); assert_eq!( g.drag_start_msg, Some( Msg::Pressed ) ); assert!( g.long_press_origin.is_some() ); } #[ test ] fn press_identifies_scroll_target() { let widgets: Vec> = vec![]; let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 200.0, 200.0 ), 42 ) ]; let mut g = GestureState::::new(); let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &scrolls ); assert_eq!( g.scrolling_widget, Some( 42 ) ); } #[ test ] fn press_resets_stale_long_press_fired_flag() { let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), Some( Msg::Pressed ), None ) ]; let mut g = GestureState::::new(); g.long_press_fired = true; // stale from a prior gesture let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] ); assert!( !g.long_press_fired ); } // ── on_move: long-press cancellation ────────────────────────────────────── #[ test ] fn move_within_six_pixels_keeps_long_press_armed() { let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), None, Some( Msg::LongPressed ) ) ]; let mut g = GestureState::::new(); let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] ); let mut offsets = HashMap::new(); let _ = g.on_move( pt( 14.0, 12.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false ); assert!( g.long_press_start.is_some() ); assert!( g.long_press_msg.is_some() ); } #[ test ] fn move_past_six_pixels_cancels_long_press() { let widgets = vec![ button( 1, rect( 0.0, 0.0, 200.0, 200.0 ), None, Some( Msg::LongPressed ) ) ]; let mut g = GestureState::::new(); let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] ); let mut offsets = HashMap::new(); let _ = g.on_move( pt( 30.0, 30.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false ); assert!( g.long_press_start.is_none() ); assert!( g.long_press_msg.is_none() ); assert!( g.long_press_origin.is_none() ); } // ── on_move: drag fork ──────────────────────────────────────────────────── #[ test ] fn move_after_long_press_fired_returns_drag() { let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), None, Some( Msg::LongPressed ) ) ]; let mut g = GestureState::::new(); let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] ); g.long_press_fired = true; let mut offsets = HashMap::new(); let out = g.on_move( pt( 50.0, 50.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false ); assert!( matches!( out, MoveOutcome::Drag { .. } ) ); } #[ test ] fn move_with_global_drag_returns_drag() { let widgets: Vec> = vec![]; let mut g = GestureState::::new(); g.start = Some( pt( 0.0, 0.0 ) ); let mut offsets = HashMap::new(); let out = g.on_move( pt( 30.0, 30.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), true ); assert!( matches!( out, MoveOutcome::Drag { .. } ) ); } // ── on_move: scroll ─────────────────────────────────────────────────────── #[ test ] fn move_inside_scroll_widget_mutates_offset_in_place() { let widgets: Vec> = vec![]; let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 7 ) ]; let mut g = GestureState::::new(); let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls ); let mut offsets = HashMap::new(); // Drag finger upward → content scrolls down → offset increases. let out = g.on_move( pt( 100.0, 60.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false ); assert!( matches!( out, MoveOutcome::Scroll ) ); assert_eq!( offsets.get( &7 ).copied(), Some( 40.0 ) ); assert!( g.scroll_drag_started, "drag past 8 px must arm scroll_drag_started" ); } #[ test ] fn move_inside_scroll_widget_clamps_offset_at_zero() { let widgets: Vec> = vec![]; let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 9 ) ]; let mut g = GestureState::::new(); let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls ); let mut offsets = HashMap::new(); // Drag finger downward → content tries to scroll up past origin → clamp at 0. let _ = g.on_move( pt( 100.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false ); assert_eq!( offsets.get( &9 ).copied(), Some( 0.0 ) ); } #[ test ] fn move_below_eight_pixels_does_not_arm_scroll_drag() { let widgets: Vec> = vec![]; let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 1 ) ]; let mut g = GestureState::::new(); let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls ); let mut offsets = HashMap::new(); let _ = g.on_move( pt( 100.0, 95.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false ); assert!( !g.scroll_drag_started ); } // ── on_move: swipe progress ─────────────────────────────────────────────── #[ test ] fn move_below_eight_pixels_emits_swipe_progress_without_arming_drag() { // The 8 px threshold only gates `*_drag_started`; the Swipe outcome // itself fires on every motion that has a non-empty axis reading. let widgets: Vec> = vec![]; let mut g = GestureState::::new(); g.start = Some( pt( 100.0, 100.0 ) ); let mut offsets = HashMap::new(); let out = g.on_move( pt( 102.0, 99.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false ); assert!( matches!( out, MoveOutcome::Swipe { .. } ) ); assert!( !g.horizontal_drag_started ); assert!( !g.vertical_drag_started ); } #[ test ] fn move_up_eleven_pixels_arms_vertical_drag() { let widgets: Vec> = vec![]; let mut g = GestureState::::new(); g.start = Some( pt( 100.0, 600.0 ) ); let mut offsets = HashMap::new(); let _ = g.on_move( pt( 100.0, 589.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false ); assert!( g.vertical_drag_started ); } #[ test ] fn move_horizontal_eleven_pixels_arms_horizontal_drag() { let widgets: Vec> = vec![]; let mut g = GestureState::::new(); g.start = Some( pt( 100.0, 600.0 ) ); let mut offsets = HashMap::new(); let _ = g.on_move( pt( 111.5, 600.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false ); assert!( g.horizontal_drag_started ); } #[ test ] fn move_up_progress_clamps_to_unit_interval() { // Surface 1000 px tall, threshold 30 % = 300 px. A 600 px upward drag // must clamp progress to 1.0 instead of overshooting to 2.0. let widgets: Vec> = vec![]; let mut g = GestureState::::new(); g.start = Some( pt( 100.0, 800.0 ) ); let mut offsets = HashMap::new(); let out = g.on_move( pt( 100.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1000 ), false ); match out { MoveOutcome::Swipe { up: Some( v ), .. } => assert!( ( v - 1.0 ).abs() < 1e-6, "expected clamp to 1.0, got {v}" ), _ => panic!( "expected Swipe with up=Some" ), } } // ── on_release ──────────────────────────────────────────────────────────── #[ test ] fn release_on_button_emits_press_msg() { let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), Some( Msg::Pressed ), None ) ]; let mut g = GestureState::::new(); let _ = g.on_press( pt( 50.0, 50.0 ), &widgets, &[] ); let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false ); assert_eq!( events.len(), 1 ); assert!( matches!( events[ 0 ], ReleaseEvent::PushMsg( Msg::Pressed ) ) ); } #[ test ] fn release_on_different_widget_does_not_emit_press_msg() { let widgets = vec![ button( 1, rect( 0.0, 0.0, 50.0, 50.0 ), Some( Msg::Pressed ), None ), button( 2, rect( 100.0, 0.0, 50.0, 50.0 ), Some( Msg::Pressed ), None ), ]; let mut g = GestureState::::new(); let _ = g.on_press( pt( 25.0, 25.0 ), &widgets, &[] ); let events = g.on_release( pt( 125.0, 25.0 ), &widgets, &cfg_full( 800, 1200 ), false ); assert!( events.iter().all( |e| !matches!( e, ReleaseEvent::PushMsg( _ ) ) ), "press_msg must not fire when release lands on a different widget" ); } #[ test ] fn release_on_empty_area_emits_empty_release() { let widgets: Vec> = vec![]; let mut g = GestureState::::new(); let _ = g.on_press( pt( 50.0, 50.0 ), &widgets, &[] ); let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false ); assert!( events.iter().any( |e| matches!( e, ReleaseEvent::EmptyRelease ) ) ); } #[ test ] fn release_after_long_press_fired_emits_drop() { let widgets = vec![ button( 1, rect( 0.0, 0.0, 100.0, 100.0 ), None, Some( Msg::LongPressed ) ) ]; let mut g = GestureState::::new(); let _ = g.on_press( pt( 50.0, 50.0 ), &widgets, &[] ); g.long_press_fired = true; let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false ); assert!( matches!( events[ 0 ], ReleaseEvent::Drop { .. } ) ); } #[ test ] fn release_with_global_drag_emits_drop() { let widgets: Vec> = vec![]; let mut g = GestureState::::new(); let _ = g.on_press( pt( 10.0, 10.0 ), &widgets, &[] ); let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), true ); assert!( matches!( events[ 0 ], ReleaseEvent::Drop { .. } ) ); } #[ test ] fn release_committing_swipe_up_emits_swipe_up() { let widgets: Vec> = vec![]; let mut g = GestureState::::new(); // No press_idx (release on empty area) so the vertical-swipe branch runs. let _ = g.on_press( pt( 400.0, 900.0 ), &widgets, &[] ); let mut offsets = HashMap::new(); // Drag well past 30 % of 1000 px to trip vertical_drag_started. let _ = g.on_move( pt( 400.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1000 ), false ); let events = g.on_release( pt( 400.0, 200.0 ), &widgets, &cfg_full( 800, 1000 ), false ); assert!( events.iter().any( |e| matches!( e, ReleaseEvent::SwipeUp ) ) ); } #[ test ] fn release_horizontal_below_threshold_falls_through() { let widgets: Vec> = vec![]; let mut g = GestureState::::new(); let _ = g.on_press( pt( 100.0, 600.0 ), &widgets, &[] ); let mut offsets = HashMap::new(); // 50 px horizontal — below 30 % of 800 = 240 — but past 8 px, so // horizontal_drag_started flips to true. let _ = g.on_move( pt( 150.0, 600.0 ), &widgets, &mut offsets, &cfg_full( 800, 1000 ), false ); let events = g.on_release( pt( 150.0, 600.0 ), &widgets, &cfg_full( 800, 1000 ), false ); assert!( events.iter().any( |e| matches!( e, ReleaseEvent::HorizontalFellThrough ) ) ); assert!( events.iter().all( |e| !matches!( e, ReleaseEvent::SwipeLeft | ReleaseEvent::SwipeRight ) ) ); } #[ test ] fn release_after_slider_drag_emits_no_events() { let widgets: Vec> = vec![]; let mut g = GestureState::::new(); g.start = Some( pt( 50.0, 50.0 ) ); g.dragging_slider = Some( 1 ); let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false ); assert!( events.is_empty() ); assert!( g.dragging_slider.is_none() ); } #[ test ] fn release_after_consumed_scroll_emits_no_events() { let widgets: Vec> = vec![]; let mut g = GestureState::::new(); g.start = Some( pt( 50.0, 50.0 ) ); g.scrolling_widget = Some( 1 ); g.scroll_drag_started = true; let events = g.on_release( pt( 50.0, 50.0 ), &widgets, &cfg_full( 800, 1200 ), false ); assert!( events.is_empty() ); assert!( g.scrolling_widget.is_none() ); assert!( !g.scroll_drag_started ); } // ── on_cancel ───────────────────────────────────────────────────────────── #[ test ] fn cancel_resets_every_field() { let mut g = GestureState::::new(); g.start = Some( pt( 1.0, 2.0 ) ); g.pressed_idx = Some( 99 ); g.scrolling_widget = Some( 99 ); g.scroll_drag_started = true; g.horizontal_drag_started = true; g.vertical_drag_started = true; g.dragging_slider = Some( 99 ); g.long_press_fired = true; g.long_press_msg = Some( Msg::LongPressed ); g.drag_start_msg = Some( Msg::Pressed ); g.on_cancel(); assert!( g.start.is_none() ); assert!( g.pressed_idx.is_none() ); assert!( g.scrolling_widget.is_none() ); assert!( !g.scroll_drag_started ); assert!( !g.horizontal_drag_started ); assert!( !g.vertical_drag_started ); assert!( g.dragging_slider.is_none() ); assert!( !g.long_press_fired ); assert!( g.long_press_msg.is_none() ); assert!( g.drag_start_msg.is_none() ); } }