refactor: split every monolithic module into focused submodules
Each source file that had grown beyond a single concern is replaced by an identically-named directory containing focused submodules. `src/event_loop/mod.rs` (878 lines) becomes a directory with clipboard, context_menu, cursor_shape, drag, focus, handlers, invalidation, overlays_reconcile, repeat, run, surface, text_editing, and tooltip. Every widget, input handler, and theme component follows the same split. Public interfaces are unchanged — only the internal file layout moves. image bumped from 0.25.2 to 0.25.9.
This commit is contained in:
524
src/input/gesture/mod.rs
Normal file
524
src/input/gesture/mod.rs
Normal file
@@ -0,0 +1,524 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Per-surface gesture state machine. Source-agnostic — both
|
||||
//! `PointerHandler` (Wayland `wl_pointer`) and `TouchHandler`
|
||||
//! (`wl_touch`) feed into the same machine and apply the decisions it
|
||||
//! emits.
|
||||
//!
|
||||
//! ## Lifecycle
|
||||
//!
|
||||
//! 1. [`GestureState::on_press`] — records the gesture origin, snapshots
|
||||
//! the hit widget's long-press AND drag-start handlers, identifies
|
||||
//! slider / scroll targets. Returns a [`PressOutcome`] with the hit
|
||||
//! index and any initial slider message.
|
||||
//! 2. [`GestureState::on_move`] — cancels the long-press candidate if
|
||||
//! the finger strays past 6 px, updates slider values, mutates
|
||||
//! scroll offsets, computes swipe progress. Returns a
|
||||
//! [`MoveOutcome`] telling the handler which app callbacks to fire.
|
||||
//! 3. [`GestureState::on_release`] — decides between drop (long-press
|
||||
//! was already fired), slider / scroll done, horizontal / vertical
|
||||
//! swipe commit or fall-through, tap or button press. Returns an
|
||||
//! ordered [`Vec<ReleaseEvent>`] — a single release can emit more
|
||||
//! than one event when a horizontal swipe falls through and the
|
||||
//! handler still has to check the vertical branch.
|
||||
//! 4. [`GestureState::on_cancel`] — resets everything (touch-cancel
|
||||
//! only; the compositor is stealing the gesture).
|
||||
//!
|
||||
//! The long-press DEADLINE itself is not polled here. `AppData`
|
||||
//! walks every surface's `gesture.long_press_start` against the app's
|
||||
//! `long_press_duration()` and flips `gesture.long_press_fired` when
|
||||
//! the deadline elapses — this module only records the start instant
|
||||
//! and reacts to the flip.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::tree::{ find_handlers, find_widget, find_widget_at };
|
||||
use crate::types::{ Point, Rect };
|
||||
use crate::widget::LaidOutWidget;
|
||||
|
||||
mod outcomes;
|
||||
|
||||
pub( crate ) use outcomes::*;
|
||||
|
||||
#[ cfg( test ) ]
|
||||
mod tests;
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Per-surface gesture tracking. Lives on `SurfaceState::gesture`.
|
||||
///
|
||||
/// Every field is `pub` because `AppData`'s long-press deadline
|
||||
/// checker in `event_loop/app_data.rs` needs to read the start instant
|
||||
/// and set `long_press_fired` from outside the state machine. The
|
||||
/// lifecycle methods ([`Self::on_press`], [`Self::on_move`],
|
||||
/// [`Self::on_release`], [`Self::on_cancel`]) are the supported way
|
||||
/// for input handlers to drive the machine; direct mutation is only
|
||||
/// for the deadline path.
|
||||
#[ derive( Debug, Clone ) ]
|
||||
pub struct GestureState<Msg: Clone>
|
||||
{
|
||||
/// Position where the current press / touch-down landed. `None`
|
||||
/// between gestures.
|
||||
pub start: Option<Point>,
|
||||
/// Widget index under the press, if any. Set on press; taken on
|
||||
/// release.
|
||||
pub pressed_idx: Option<usize>,
|
||||
/// Index of the Scroll viewport that owns the current gesture, if
|
||||
/// the press landed inside one.
|
||||
pub scrolling_widget: Option<usize>,
|
||||
/// Scroll-viewport drag exceeded the 8 px start tolerance — the
|
||||
/// release will be consumed as a scroll instead of a tap.
|
||||
pub scroll_drag_started: bool,
|
||||
/// Horizontal drag exceeded the 8 px threshold. Release runs the
|
||||
/// horizontal-swipe commit check instead of the button path.
|
||||
pub horizontal_drag_started: bool,
|
||||
/// Vertical drag exceeded the 8 px threshold. Release runs the
|
||||
/// swipe-up / swipe-down commit check instead of the button path.
|
||||
pub vertical_drag_started: bool,
|
||||
/// Slider widget being dragged for a live-value update.
|
||||
pub dragging_slider: Option<usize>,
|
||||
/// Instant when the long-press window opened. `None` if the hit
|
||||
/// widget has no long-press handler (or the candidate was
|
||||
/// cancelled by movement).
|
||||
pub long_press_start: Option<Instant>,
|
||||
/// Original press position for the long-press candidate — used by
|
||||
/// [`Self::on_move`] to cancel if the finger strays more than 6 px.
|
||||
pub long_press_origin: Option<Point>,
|
||||
/// Message to fire when the deadline elapses (touch hold) or when
|
||||
/// the user right-clicks. Drained by the deadline checker on touch
|
||||
/// and by the right-click handler on mouse. Firing this on its own
|
||||
/// does NOT put the gesture into drag mode — that is governed by
|
||||
/// [`Self::drag_start_msg`].
|
||||
pub long_press_msg: Option<Msg>,
|
||||
/// Drag-arm message captured from the hit widget on press. Fired
|
||||
/// by the touch deadline alongside `long_press_msg` and by the
|
||||
/// mouse-motion drag-promotion path on its own. When fired, the
|
||||
/// gesture transitions into drag mode (`long_press_fired = true`)
|
||||
/// and subsequent motion / release run through `on_drag_move` /
|
||||
/// `on_drop`.
|
||||
pub drag_start_msg: Option<Msg>,
|
||||
/// `widget_rects` index of a TextEdit the press landed on, when
|
||||
/// no user `on_long_press` is configured. Lets the runtime show
|
||||
/// the built-in Copy / Cut / Paste menu after the same delay
|
||||
/// without forcing every text field to opt in.
|
||||
pub long_press_text_idx: Option<usize>,
|
||||
/// `true` once the gesture has transitioned into drag mode —
|
||||
/// subsequent motion fires `app.on_drag_move`, release fires
|
||||
/// `app.on_drop`. Set by the touch deadline when `drag_start_msg`
|
||||
/// is present, or by the mouse-motion promotion path.
|
||||
pub long_press_fired: bool,
|
||||
/// `true` when the press came from a mouse (`wl_pointer`) rather
|
||||
/// than touch. The 6 px stray-cancel in [`Self::on_move`] is gated
|
||||
/// on this: touch needs the cancel so a hold that turns into a
|
||||
/// swipe / scroll doesn't keep the long-press candidate armed,
|
||||
/// but mouse motion is always intentional and never competes with
|
||||
/// a swipe gesture, so the candidate stays alive until the
|
||||
/// pointer-side promotion threshold or the deadline fires. Set by
|
||||
/// the pointer Press handler right after [`Self::on_press`]; left
|
||||
/// `false` for touch presses.
|
||||
pub mouse_press: bool,
|
||||
}
|
||||
|
||||
impl<Msg: Clone> Default for GestureState<Msg>
|
||||
{
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
impl<Msg: Clone> GestureState<Msg>
|
||||
{
|
||||
/// Fresh state — no press in flight.
|
||||
pub fn new() -> Self
|
||||
{
|
||||
Self
|
||||
{
|
||||
start: None,
|
||||
pressed_idx: None,
|
||||
scrolling_widget: None,
|
||||
scroll_drag_started: false,
|
||||
horizontal_drag_started: false,
|
||||
vertical_drag_started: false,
|
||||
dragging_slider: None,
|
||||
long_press_start: None,
|
||||
long_press_origin: None,
|
||||
long_press_msg: None,
|
||||
drag_start_msg: None,
|
||||
long_press_text_idx: None,
|
||||
long_press_fired: false,
|
||||
mouse_press: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Press / touch-down: record the gesture origin, capture long-press
|
||||
/// handler if the hit widget has one, identify slider / scroll
|
||||
/// targets. Returns the hit index (for keyboard focus update) and
|
||||
/// any initial slider message (the slider applies its value
|
||||
/// immediately on press so the thumb tracks the finger from pixel
|
||||
/// one).
|
||||
pub fn on_press
|
||||
(
|
||||
&mut self,
|
||||
pos: Point,
|
||||
widget_rects: &[LaidOutWidget<Msg>],
|
||||
scroll_rects: &[( Rect, usize )],
|
||||
) -> PressOutcome<Msg>
|
||||
{
|
||||
let hit = find_widget_at( widget_rects, pos );
|
||||
let ( lp_msg, ds_msg ) = hit.map( |idx|
|
||||
{
|
||||
let h = find_handlers( widget_rects, idx );
|
||||
(
|
||||
h.as_ref().and_then( |h| h.long_press_msg() ),
|
||||
h.as_ref().and_then( |h| h.drag_start_msg() ),
|
||||
)
|
||||
}).unwrap_or( ( None, None ) );
|
||||
|
||||
self.start = Some( pos );
|
||||
self.scrolling_widget = scroll_rects.iter().rev()
|
||||
.find( |( r, _ )| r.contains( pos ) )
|
||||
.map( |( _, idx )| *idx );
|
||||
self.scroll_drag_started = false;
|
||||
self.horizontal_drag_started = false;
|
||||
self.vertical_drag_started = false;
|
||||
self.pressed_idx = hit;
|
||||
// A fresh press resets any stale long-press / source state
|
||||
// from a prior gesture that never released cleanly. The
|
||||
// pointer Press handler flips `mouse_press` back to true
|
||||
// right after this call when the press came from a mouse;
|
||||
// touch leaves it `false`.
|
||||
self.long_press_fired = false;
|
||||
self.mouse_press = false;
|
||||
// Built-in long-press for text inputs: arms the same timer
|
||||
// even when the widget has no user-set `on_long_press`, so
|
||||
// the runtime can show the Copy / Cut / Paste menu after the
|
||||
// usual delay.
|
||||
let text_idx = hit.and_then( |idx|
|
||||
{
|
||||
find_handlers( widget_rects, idx )
|
||||
.and_then( |h| if h.is_text_input() { Some( idx ) } else { None } )
|
||||
} );
|
||||
// Arm the long-press timer if anything is pending: the menu
|
||||
// message, the drag-start message, or the built-in text menu.
|
||||
// A widget that only carries `on_drag_start` (no menu) still
|
||||
// needs the timer so a touch hold can promote into a drag.
|
||||
let arm_long_press = lp_msg.is_some() || ds_msg.is_some() || text_idx.is_some();
|
||||
self.long_press_start = arm_long_press.then( Instant::now );
|
||||
self.long_press_origin = arm_long_press.then_some( pos );
|
||||
self.long_press_msg = lp_msg;
|
||||
self.drag_start_msg = ds_msg;
|
||||
self.long_press_text_idx = text_idx;
|
||||
|
||||
// Slider drag: if the hit widget is a slider, arm the drag and
|
||||
// apply the press-point value immediately.
|
||||
let mut initial_slider_msg = None;
|
||||
if let Some( idx ) = hit
|
||||
{
|
||||
if let Some( w ) = find_widget( widget_rects, idx )
|
||||
{
|
||||
if w.handlers.is_slider()
|
||||
{
|
||||
self.dragging_slider = Some( idx );
|
||||
let value = w.handlers.slider_value_from_pos( w.rect, pos );
|
||||
initial_slider_msg = w.handlers.slider_change_msg( value );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PressOutcome { hit_idx: hit, initial_slider_msg }
|
||||
}
|
||||
|
||||
/// Move / touch-motion: classify the motion and mutate scroll
|
||||
/// offsets in place. `global_drag` is `true` when another surface
|
||||
/// has an active long-press drag (cross-surface drags route every
|
||||
/// motion through `on_drag_move` even on surfaces that didn't
|
||||
/// start the gesture).
|
||||
pub fn on_move
|
||||
(
|
||||
&mut self,
|
||||
pos: Point,
|
||||
widget_rects: &[LaidOutWidget<Msg>],
|
||||
scroll_offsets: &mut HashMap<usize, f32>,
|
||||
swipe: &SwipeConfig,
|
||||
global_drag: bool,
|
||||
) -> MoveOutcome<Msg>
|
||||
{
|
||||
// Drag phase: once the long-press has fired, every motion
|
||||
// goes to the app's drag handler and bypasses slider / scroll /
|
||||
// swipe logic.
|
||||
if self.long_press_fired || global_drag
|
||||
{
|
||||
return MoveOutcome::Drag { pos };
|
||||
}
|
||||
|
||||
// Cancel the long-press candidate if the finger strayed > 6 px
|
||||
// — but ONLY for touch. Touch needs this so a hold that turns
|
||||
// into a swipe / scroll doesn't keep the menu candidate
|
||||
// armed and fire mid-swipe; mouse motion is always
|
||||
// intentional and never competes with a swipe, so the
|
||||
// candidate stays alive until the pointer-side promotion at
|
||||
// 24 px (or the hold-timer deadline) consumes it. Without
|
||||
// this gate, mouse motion in the 6–24 px band would clear
|
||||
// `drag_start_msg` before promotion could fire and the user
|
||||
// would have to wait out the full 500 ms hold timer to drag.
|
||||
if !self.mouse_press
|
||||
{
|
||||
if let Some( origin ) = self.long_press_origin
|
||||
{
|
||||
let d = ( pos.x - origin.x ).hypot( pos.y - origin.y );
|
||||
if d > 6.0
|
||||
{
|
||||
self.long_press_start = None;
|
||||
self.long_press_origin = None;
|
||||
self.long_press_msg = None;
|
||||
self.drag_start_msg = None;
|
||||
self.long_press_text_idx = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Slider drag: continuously update value.
|
||||
if let Some( slider_idx ) = self.dragging_slider
|
||||
{
|
||||
if let Some( w ) = find_widget( widget_rects, slider_idx )
|
||||
{
|
||||
let value = w.handlers.slider_value_from_pos( w.rect, pos );
|
||||
let msg = w.handlers.slider_change_msg( value );
|
||||
return MoveOutcome::Slider { msg };
|
||||
}
|
||||
return MoveOutcome::Idle;
|
||||
}
|
||||
|
||||
// Scroll viewport drag: mutate offset in place and advance the
|
||||
// gesture origin so the next delta is frame-to-frame, not
|
||||
// press-to-now (otherwise the first 8 px trip the threshold
|
||||
// and the entire scroll gets absorbed into one delta).
|
||||
if let Some( scroll_idx ) = self.scrolling_widget
|
||||
{
|
||||
if let Some( start ) = self.start
|
||||
{
|
||||
let dy = pos.y - start.y;
|
||||
let entry = scroll_offsets.entry( scroll_idx ).or_insert( 0.0 );
|
||||
*entry = ( *entry - dy ).max( 0.0 );
|
||||
if dy.abs() > 8.0 { self.scroll_drag_started = true; }
|
||||
self.start = Some( pos );
|
||||
return MoveOutcome::Scroll;
|
||||
}
|
||||
return MoveOutcome::Idle;
|
||||
}
|
||||
|
||||
// Swipe progress — independent on both axes so a vertical-
|
||||
// dominant gesture that picks up horizontal drift still drives
|
||||
// the pager.
|
||||
if let Some( start ) = self.start
|
||||
{
|
||||
let dy = start.y - pos.y;
|
||||
let dx = pos.x - start.x;
|
||||
|
||||
let ( up, down ) = if swipe.surface_height > 0
|
||||
{
|
||||
let h = swipe.surface_height as f32;
|
||||
if dy > 0.0
|
||||
{
|
||||
let threshold = h * swipe.up_thresh;
|
||||
if dy.abs() > 8.0 { self.vertical_drag_started = true; }
|
||||
( Some( ( dy / threshold ).clamp( 0.0, 1.0 ) ), None )
|
||||
}
|
||||
else if start.y <= h * swipe.down_edge
|
||||
{
|
||||
let threshold = h * swipe.down_thresh;
|
||||
if dy.abs() > 8.0 { self.vertical_drag_started = true; }
|
||||
// Unclamped on purpose — lets follow-the-finger
|
||||
// panels track past the commit threshold.
|
||||
( None, Some( ( -dy / threshold ).max( 0.0 ) ) )
|
||||
}
|
||||
else { ( None, None ) }
|
||||
}
|
||||
else { ( None, None ) };
|
||||
|
||||
let horizontal = if swipe.surface_width > 0
|
||||
{
|
||||
let h_thr = swipe.surface_width as f32 * swipe.horizontal_thresh;
|
||||
let h_progress = if h_thr > 0.0 { dx / h_thr } else { 0.0 };
|
||||
if dx.abs() > 8.0 { self.horizontal_drag_started = true; }
|
||||
Some( h_progress )
|
||||
}
|
||||
else { None };
|
||||
|
||||
if up.is_some() || down.is_some() || horizontal.is_some()
|
||||
{
|
||||
return MoveOutcome::Swipe { up, down, horizontal };
|
||||
}
|
||||
}
|
||||
|
||||
MoveOutcome::Idle
|
||||
}
|
||||
|
||||
/// Release / touch-up: decide between drop / slider done / scroll
|
||||
/// done / swipe commit / swipe fall-through / button press / tap.
|
||||
///
|
||||
/// A release can emit multiple events: a horizontal swipe that
|
||||
/// didn't commit still fires `on_swipe_horizontal_progress(0.0)`
|
||||
/// before falling through to the vertical branch, and a below-
|
||||
/// threshold vertical swipe pulses the vertical progress
|
||||
/// callbacks. The handler dispatches the events in order.
|
||||
///
|
||||
/// `global_drag` mirrors the parameter of [`Self::on_move`].
|
||||
pub fn on_release
|
||||
(
|
||||
&mut self,
|
||||
pos: Point,
|
||||
widget_rects: &[LaidOutWidget<Msg>],
|
||||
swipe: &SwipeConfig,
|
||||
global_drag: bool,
|
||||
) -> Vec<ReleaseEvent<Msg>>
|
||||
{
|
||||
let pressed = self.pressed_idx.take();
|
||||
let was_dragging_slider = self.dragging_slider.is_some();
|
||||
let long_press_fired = self.long_press_fired;
|
||||
let horizontal_drag_started = self.horizontal_drag_started;
|
||||
let vertical_drag_started = self.vertical_drag_started;
|
||||
|
||||
// Drain all the drag / long-press slots. A release ALWAYS
|
||||
// closes the long-press window: if the deadline fired we've
|
||||
// already consumed the msg; if not, the candidate is cancelled.
|
||||
self.dragging_slider = None;
|
||||
self.long_press_start = None;
|
||||
self.long_press_origin = None;
|
||||
self.long_press_msg = None;
|
||||
self.drag_start_msg = None;
|
||||
self.long_press_text_idx = None;
|
||||
self.long_press_fired = false;
|
||||
self.mouse_press = false;
|
||||
self.horizontal_drag_started = false;
|
||||
self.vertical_drag_started = false;
|
||||
|
||||
let mut events = Vec::new();
|
||||
|
||||
// Drop — long-press had fired earlier in the gesture.
|
||||
if long_press_fired || global_drag
|
||||
{
|
||||
self.start = None;
|
||||
events.push( ReleaseEvent::Drop { pos } );
|
||||
return events;
|
||||
}
|
||||
|
||||
// Slider drag complete — not a swipe, not a tap.
|
||||
if was_dragging_slider
|
||||
{
|
||||
self.scroll_drag_started = false;
|
||||
self.start = None;
|
||||
return events;
|
||||
}
|
||||
|
||||
// Scroll gesture consumed.
|
||||
let scroll_consumed = self.scrolling_widget.take().is_some()
|
||||
&& self.scroll_drag_started;
|
||||
self.scroll_drag_started = false;
|
||||
if scroll_consumed
|
||||
{
|
||||
self.start = None;
|
||||
return events;
|
||||
}
|
||||
|
||||
// Horizontal swipe commit / fall-through. On commit we return
|
||||
// immediately; on fall-through we send the 0.0 pulse, keep
|
||||
// `self.start` for the vertical branch, and continue.
|
||||
if horizontal_drag_started
|
||||
{
|
||||
if let Some( start ) = self.start
|
||||
{
|
||||
let dx = pos.x - start.x;
|
||||
let width = swipe.surface_width;
|
||||
let committed = width > 0
|
||||
&& dx.abs() >= width as f32 * swipe.horizontal_thresh;
|
||||
if committed
|
||||
{
|
||||
self.start = None;
|
||||
events.push( if dx < 0.0 { ReleaseEvent::SwipeLeft } else { ReleaseEvent::SwipeRight } );
|
||||
return events;
|
||||
}
|
||||
events.push( ReleaseEvent::HorizontalFellThrough );
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical swipe commit / fall-through. Consumes `self.start`.
|
||||
if vertical_drag_started || pressed.is_none()
|
||||
{
|
||||
if let Some( start ) = self.start.take()
|
||||
{
|
||||
let dy = start.y - pos.y;
|
||||
let height = swipe.surface_height as f32;
|
||||
let up_thr = height * swipe.up_thresh;
|
||||
let down_thr = height * swipe.down_thresh;
|
||||
let down_edge = height * swipe.down_edge;
|
||||
if dy >= up_thr
|
||||
{
|
||||
events.push( ReleaseEvent::SwipeUp );
|
||||
return events;
|
||||
}
|
||||
else if -dy >= down_thr && start.y <= down_edge
|
||||
{
|
||||
events.push( ReleaseEvent::SwipeDown );
|
||||
return events;
|
||||
}
|
||||
else
|
||||
{
|
||||
events.push( ReleaseEvent::VerticalFellThrough );
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
self.start = None;
|
||||
}
|
||||
|
||||
// Any drag started — skip the button path so a gesture that
|
||||
// fell through without committing does not also trigger a tap.
|
||||
if horizontal_drag_started || vertical_drag_started
|
||||
{
|
||||
return events;
|
||||
}
|
||||
|
||||
// Button / slider press on release: the release must land on
|
||||
// the same widget the press did.
|
||||
let release_hit = find_widget_at( widget_rects, pos );
|
||||
if let Some( idx ) = pressed
|
||||
{
|
||||
if release_hit == Some( idx )
|
||||
{
|
||||
if let Some( w ) = find_widget( widget_rects, idx )
|
||||
{
|
||||
let value = w.handlers.slider_value_from_pos( w.rect, pos );
|
||||
if let Some( msg ) = w.handlers.slider_change_msg( value )
|
||||
{
|
||||
events.push( ReleaseEvent::PushMsg( msg ) );
|
||||
return events;
|
||||
}
|
||||
// Repeating buttons fire `press_msg` on *press* (and
|
||||
// keep firing through the calloop repeat timer
|
||||
// while held) — suppressing the release-time fire
|
||||
// avoids a double tap on a quick click.
|
||||
if !w.handlers.is_repeating()
|
||||
{
|
||||
if let Some( msg ) = w.handlers.press_msg()
|
||||
{
|
||||
events.push( ReleaseEvent::PushMsg( msg ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if release_hit.is_none()
|
||||
{
|
||||
// Release on empty area — handler decides Tap vs
|
||||
// overlay-dismiss based on focus.
|
||||
events.push( ReleaseEvent::EmptyRelease );
|
||||
}
|
||||
|
||||
events
|
||||
}
|
||||
|
||||
/// Touch-cancel: drop all in-flight gesture state.
|
||||
pub fn on_cancel( &mut self ) { *self = Self::new(); }
|
||||
}
|
||||
Reference in New Issue
Block a user