`Button::tooltip( text )` registers a hint string that fires after a 600 ms pointer dwell. `LaidOutWidget` gains a `tooltip: Option<String>` field, `Element::tooltip()` exposes it to the input layer, and the existing pointer-hover path now calls `arm_tooltip` on hover-enter and `cancel_tooltip` on hover-leave / touch. The deadline is polled alongside `next_long_press_wakeup` in `try_run` so an idle pointer still gets a wake-up at the firing instant; on fire, `tooltip_overlay()` synthesises an `OverlaySpec` — a rounded `text_primary @ 95%` pill drawn with `bg` text — anchored above the hovered widget, flipping below or clamping inside the screen if it would clip, and pushed alongside the app's own overlays both in the redraw path and in `reconcile_overlays` so the layer surface is created the same frame the tooltip becomes visible. Pointer-only by design: touch events explicitly cancel because a tap-and-release should never linger into a hint. The `showcase` example wires `.tooltip(..)` on the three button variants as a smoke test. The drag pipeline now reports positions in main-surface (global) coordinates instead of per-surface. `surface_offset_for( focus )` derives the top-left of an overlay surface from `SurfaceState::layer_anchor` — newly stored at `reconcile_overlays` time from `OverlaySpec::anchor` — combined with the main surface's dimensions; `on_drag_move`, `on_drop`, the synthetic move emitted on drag-promotion in `pointer.rs`, and the `pending_drag_inits` push site in `gesture::start_drag` all translate before handing coordinates to the app. The motion and release paths additionally `request_redraw()` every overlay so a dock-style drop target painted on an `Anchor::Bottom` layer surface gets repainted as the drag moves — without that, the visible drop indicator only updates when the cursor re-enters the main surface. Drops still target whichever surface fired the release; only the coordinates are unified. `ForeignToplevelListHandler` previously read `app_id` directly via `ForeignToplevelList::info()`. Clients that never set `app_id` (some winit-windowed compositors, simple test clients) were silently invisible to crustace-style docks because the empty string fell through `unwrap_or_default()` and the dock then keyed entries off `""`. `toplevel_display_id()` cascades: prefer `app_id` for desktop-entry matching, fall back to `title` for human-readable identification, and finally to the protocol-issued `identifier` which is always present and unique per handle. Applied to both `new_toplevel` and `update_toplevel`. `theme::system_fontdb()` lazily loads the system font database once via `OnceLock` and reuses the `Arc` for every `decode_svg_bytes` call. resvg's default `Options::fontdb` is empty, so any SVG containing `<text>` rendered with the built-in fallback font or no font at all; with the system DB attached, icons and decorative SVGs with embedded labels now resolve glyphs correctly. Cached because `load_system_fonts()` walks every font path on the system and is comfortably tens of milliseconds on a cold cache — not something to repeat per icon decode. `themes/default/theme.json` tweaks one variant's slot palette: `surface-alt` from `@indigo/D9` to `@white/D9` and `text-primary` from `@white` to `@navy`, plus a cosmetic re-alignment of the `"value"` columns in the same slots block.
1072 lines
39 KiB
Rust
1072 lines
39 KiB
Rust
// 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;
|
||
|
||
// ─── 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(); }
|
||
}
|
||
|
||
// ─── Outcomes ────────────────────────────────────────────────────────────────
|
||
|
||
/// Result of [`GestureState::on_press`]. Handler uses `hit_idx` to set
|
||
/// keyboard focus; pushes `initial_slider_msg` to the pending queue
|
||
/// when present.
|
||
pub struct PressOutcome<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,
|
||
}
|
||
|
||
#[ cfg( test ) ]
|
||
mod tests
|
||
{
|
||
use super::*;
|
||
use crate::widget::{ LaidOutWidget, WidgetHandlers };
|
||
|
||
#[ derive( Clone, Debug, PartialEq, Eq ) ]
|
||
enum Msg
|
||
{
|
||
Pressed,
|
||
LongPressed,
|
||
}
|
||
|
||
fn rect( x: f32, y: f32, w: f32, h: f32 ) -> Rect
|
||
{
|
||
Rect { x, y, width: w, height: h }
|
||
}
|
||
|
||
fn pt( x: f32, y: f32 ) -> Point
|
||
{
|
||
Point { x, y }
|
||
}
|
||
|
||
fn button( idx: usize, r: Rect, on_press: Option<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() );
|
||
}
|
||
}
|