refactor: split every monolithic module into focused submodules
Each source file that had grown beyond a single concern is replaced by an identically-named directory containing focused submodules. `src/event_loop/mod.rs` (878 lines) becomes a directory with clipboard, context_menu, cursor_shape, drag, focus, handlers, invalidation, overlays_reconcile, repeat, run, surface, text_editing, and tooltip. Every widget, input handler, and theme component follows the same split. Public interfaces are unchanged — only the internal file layout moves. image bumped from 0.25.2 to 0.25.9.
This commit is contained in:
524
src/input/gesture/mod.rs
Normal file
524
src/input/gesture/mod.rs
Normal file
@@ -0,0 +1,524 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! 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<ReleaseEvent>`] — 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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 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<Msg: Clone>
|
||||
{
|
||||
/// Position where the current press / touch-down landed. `None`
|
||||
/// between gestures.
|
||||
pub start: Option<Point>,
|
||||
/// Widget index under the press, if any. Set on press; taken on
|
||||
/// release.
|
||||
pub pressed_idx: Option<usize>,
|
||||
/// Index of the Scroll viewport that owns the current gesture, if
|
||||
/// the press landed inside one.
|
||||
pub scrolling_widget: Option<usize>,
|
||||
/// 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<usize>,
|
||||
/// 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<Instant>,
|
||||
/// 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<Point>,
|
||||
/// 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<Msg>,
|
||||
/// 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<Msg>,
|
||||
/// `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<usize>,
|
||||
/// `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<Msg: Clone> Default for GestureState<Msg>
|
||||
{
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
impl<Msg: Clone> GestureState<Msg>
|
||||
{
|
||||
/// 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<Msg>],
|
||||
scroll_rects: &[( Rect, usize )],
|
||||
) -> PressOutcome<Msg>
|
||||
{
|
||||
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<Msg>],
|
||||
scroll_offsets: &mut HashMap<usize, f32>,
|
||||
swipe: &SwipeConfig,
|
||||
global_drag: bool,
|
||||
) -> MoveOutcome<Msg>
|
||||
{
|
||||
// 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<Msg>],
|
||||
swipe: &SwipeConfig,
|
||||
global_drag: bool,
|
||||
) -> Vec<ReleaseEvent<Msg>>
|
||||
{
|
||||
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(); }
|
||||
}
|
||||
84
src/input/gesture/outcomes.rs
Normal file
84
src/input/gesture/outcomes.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
use crate::types::Point;
|
||||
|
||||
/// 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<Msg>
|
||||
{
|
||||
pub hit_idx: Option<usize>,
|
||||
pub initial_slider_msg: Option<Msg>,
|
||||
}
|
||||
|
||||
/// Result of [`GestureState::on_move`]. Handler turns this into app
|
||||
/// callbacks + redraw flags.
|
||||
pub enum MoveOutcome<Msg>
|
||||
{
|
||||
/// 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<Msg> },
|
||||
/// 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<f32>, down: Option<f32>, horizontal: Option<f32> },
|
||||
}
|
||||
|
||||
/// 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<Msg>
|
||||
{
|
||||
/// 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,
|
||||
}
|
||||
470
src/input/gesture/tests.rs
Normal file
470
src/input/gesture/tests.rs
Normal file
@@ -0,0 +1,470 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
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<Msg>, on_long_press: Option<Msg> )
|
||||
-> LaidOutWidget<Msg>
|
||||
{
|
||||
button_full( idx, r, on_press, on_long_press, None )
|
||||
}
|
||||
|
||||
fn button_full(
|
||||
idx: usize, r: Rect,
|
||||
on_press: Option<Msg>, on_long_press: Option<Msg>, on_drag_start: Option<Msg>,
|
||||
) -> LaidOutWidget<Msg>
|
||||
{
|
||||
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::<Msg>::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::<Msg>::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::<Msg>::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::<Msg>::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::<Msg>::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::<Msg>::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::<Msg>::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<LaidOutWidget<Msg>> = vec![];
|
||||
let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 200.0, 200.0 ), 42 ) ];
|
||||
let mut g = GestureState::<Msg>::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::<Msg>::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::<Msg>::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::<Msg>::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::<Msg>::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<LaidOutWidget<Msg>> = vec![];
|
||||
let mut g = GestureState::<Msg>::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<LaidOutWidget<Msg>> = vec![];
|
||||
let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 7 ) ];
|
||||
let mut g = GestureState::<Msg>::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<LaidOutWidget<Msg>> = vec![];
|
||||
let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 9 ) ];
|
||||
let mut g = GestureState::<Msg>::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<LaidOutWidget<Msg>> = vec![];
|
||||
let scrolls: [( Rect, usize ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 1 ) ];
|
||||
let mut g = GestureState::<Msg>::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<LaidOutWidget<Msg>> = vec![];
|
||||
let mut g = GestureState::<Msg>::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<LaidOutWidget<Msg>> = vec![];
|
||||
let mut g = GestureState::<Msg>::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<LaidOutWidget<Msg>> = vec![];
|
||||
let mut g = GestureState::<Msg>::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<LaidOutWidget<Msg>> = vec![];
|
||||
let mut g = GestureState::<Msg>::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::<Msg>::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::<Msg>::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<LaidOutWidget<Msg>> = vec![];
|
||||
let mut g = GestureState::<Msg>::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::<Msg>::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<LaidOutWidget<Msg>> = vec![];
|
||||
let mut g = GestureState::<Msg>::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<LaidOutWidget<Msg>> = vec![];
|
||||
let mut g = GestureState::<Msg>::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<LaidOutWidget<Msg>> = vec![];
|
||||
let mut g = GestureState::<Msg>::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<LaidOutWidget<Msg>> = vec![];
|
||||
let mut g = GestureState::<Msg>::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<LaidOutWidget<Msg>> = vec![];
|
||||
let mut g = GestureState::<Msg>::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::<Msg>::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() );
|
||||
}
|
||||
Reference in New Issue
Block a user