// 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; mod outcomes; pub( crate ) use outcomes::*; #[ cfg( test ) ] mod tests; // ─── State ─────────────────────────────────────────────────────────────────── /// Axis a swipe locked onto during its first 8 px of travel. Once set, /// the perpendicular axis is ignored for the rest of the gesture so a /// vertical swipe that drifts sideways never also drives the pager (and /// vice-versa). #[ derive( Debug, Clone, Copy, PartialEq, Eq ) ] pub enum SwipeAxis { Vertical, Horizontal, } /// 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<( usize, crate::widget::scroll::ScrollAxis )>, /// Scroll viewports containing the press, innermost first. pub scroll_candidates: Vec<( usize, crate::widget::scroll::ScrollAxis )>, /// `true` once the first 8 px of motion has pinned `scrolling_widget` to a definite axis. pub scroll_locked: bool, /// 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, /// Axis this swipe locked onto on its first 8 px of travel. Pins the /// gesture to one axis so vertical and horizontal swipes stay /// mutually exclusive. `None` until the lock fires. pub swipe_axis: Option, /// 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, /// Accumulated downward overscroll (physical px) while a vertical /// scroll is pinned at its top — built up by [`Self::on_move`] when a /// pull-down can no longer scroll content, unwound by upward drag, and /// drained on release into an `OverscrollDown` event. Drives /// pull-from-top-to-dismiss. pub overscroll_y: f32, /// `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_candidates: Vec::new(), scroll_locked: false, scroll_drag_started: false, horizontal_drag_started: false, vertical_drag_started: false, swipe_axis: None, 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, overscroll_y: 0.0, 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, crate::widget::scroll::ScrollAxis )], ) -> 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.scroll_candidates = scroll_rects.iter() .filter( |( r, _, _ )| r.contains( pos ) ) .map( |( _, idx, ax )| ( *idx, *ax ) ) .collect(); self.scrolling_widget = self.scroll_candidates.first().copied(); self.scroll_locked = false; self.scroll_drag_started = false; self.overscroll_y = 0.0; self.horizontal_drag_started = false; self.vertical_drag_started = false; self.swipe_axis = None; 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; } if self.scrolling_widget.is_some() { if let Some( start ) = self.start { let dx = pos.x - start.x; let dy = pos.y - start.y; if !self.scroll_locked { if dx.abs() <= 8.0 && dy.abs() <= 8.0 { return MoveOutcome::Idle; } let prefer_x = dx.abs() > dy.abs(); if let Some( c ) = self.scroll_candidates.iter() .find( |( _, ax )| if prefer_x { ax.allows_x() } else { ax.allows_y() } ) .copied() { self.scrolling_widget = Some( c ); } self.scroll_locked = true; } let ( scroll_idx, axis ) = self.scrolling_widget.unwrap(); let entry = scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) ); if axis.allows_x() { entry.0 = ( entry.0 - dx ).max( 0.0 ); } if axis.allows_y() { // Split the vertical delta between content scroll and top // overscroll: a downward pull first scrolls toward the top, // then any excess (the offset can't go below 0) feeds // `overscroll_y`; an upward pull unwinds that overscroll // before it resumes scrolling content. if dy >= 0.0 { let consume = dy.min( entry.1 ); entry.1 -= consume; self.overscroll_y += dy - consume; } else { let up = -dy; let consume = up.min( self.overscroll_y ); self.overscroll_y -= consume; entry.1 += up - consume; } } let moved = if axis.allows_x() && axis.allows_y() { dx.hypot( dy ) } else if axis.allows_x() { dx.abs() } else { dy.abs() }; if moved > 8.0 { self.scroll_drag_started = true; } self.start = Some( pos ); // Gate the dismiss on the same 8 px arm as a real scroll drag so // tap jitter at the top can't engage a follow-the-finger close. if self.overscroll_y > 0.0 && axis.allows_y() && self.scroll_drag_started { let progress = if swipe.surface_height > 0 { self.overscroll_y / swipe.surface_height as f32 } else { 0.0 }; return MoveOutcome::OverscrollDown { progress }; } return MoveOutcome::Scroll; } return MoveOutcome::Idle; } // Swipe progress — locked to a single axis (see below) so a // gesture only ever drives one of the vertical panels or the // horizontal pager, never both. if let Some( start ) = self.start { let dy = start.y - pos.y; let dx = pos.x - start.x; // Lock onto the dominant axis on the first 8 px of travel so // vertical and horizontal swipes stay mutually exclusive. if self.swipe_axis.is_none() && dx.abs().max( dy.abs() ) > 8.0 { self.swipe_axis = Some( if dy.abs() >= dx.abs() { SwipeAxis::Vertical } else { SwipeAxis::Horizontal } ); } let vertical_allowed = self.swipe_axis != Some( SwipeAxis::Horizontal ); let ( up, down ) = if vertical_allowed && 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; } // Unclamped on purpose — lets follow-the-finger // panels track past the commit threshold. ( Some( ( dy / threshold ).max( 0.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 ) }; // Only drive the horizontal pager once the gesture has LOCKED onto the // horizontal axis. Emitting progress while `swipe_axis` is still None // would activate the pager from the sub-8 px lateral jitter at the // start of a vertical swipe; `horizontal_drag_started` would then never // trip, so the release emits no horizontal event and the pager stays // stuck active (frozen homescreen). Locking to Horizontal implies // `dx.abs() > 8.0`, so the drag is unambiguously started here. let horizontal = if self.swipe_axis == Some( SwipeAxis::Horizontal ) && 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 }; 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; // A pull past the top releases into an overscroll-dismiss event; // the app decides whether the accumulated pull committed. let over = self.overscroll_y; self.overscroll_y = 0.0; if over > 0.0 { let threshold = swipe.surface_height as f32 * swipe.overscroll_thresh; events.push( ReleaseEvent::OverscrollDown { committed: over >= threshold } ); } 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(); } }