refactor: split every monolithic module into focused submodules

Each source file that had grown beyond a single concern is replaced by an identically-named directory containing focused submodules. `src/event_loop/mod.rs` (878 lines) becomes a directory with clipboard, context_menu, cursor_shape, drag, focus, handlers, invalidation, overlays_reconcile, repeat, run, surface, text_editing, and tooltip. Every widget, input handler, and theme component follows the same split. Public interfaces are unchanged — only the internal file layout moves.
image bumped from 0.25.2 to 0.25.9.
This commit is contained in:
2026-05-15 23:46:56 +02:00
parent 3d237039c6
commit 4aa3480b64
155 changed files with 13832 additions and 13035 deletions

524
src/input/gesture/mod.rs Normal file
View File

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