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:
2026-05-15 23:46:56 +02:00
parent 3d237039c6
commit 4aa3480b64
155 changed files with 13832 additions and 13035 deletions

524
src/input/gesture/mod.rs Normal file
View 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 624 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(); }
}

View 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
View 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() );
}