Files
ltk/src/input/gesture.rs
Pedro M. de Echanove Pasquin bfe27b6fef event_loop, widget, input: pointer-dwell tooltips, global drag coords, foreign-toplevel name cascade
`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.
2026-05-14 22:36:17 +02:00

1072 lines
39 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 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(); }
}
// ─── 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() );
}
}