Skip to main content

azul_layout/managers/
gesture.rs

1//! Gesture and drag manager for multi-frame gestures and drag operations.
2//!
3//! Collects input samples, detects drags, double-clicks, long presses, swipes,
4//! pinch/rotate gestures, and manages drag state for nodes, windows, and file drops.
5//!
6//! ## Unified Drag System
7//!
8//! This module uses the `DragContext` from `azul_core::drag` to provide a unified
9//! interface for all drag operations:
10//! - Text selection drag
11//! - Scrollbar thumb drag
12//! - Node drag-and-drop
13//! - Window drag/resize
14//! - File drop from OS
15
16use alloc::vec::Vec;
17#[cfg(feature = "std")]
18use std::sync::atomic::{AtomicU64, Ordering};
19
20use azul_core::{
21    dom::{DomId, NodeId},
22    drag::{ActiveDragType, AutoScrollDirection, DragContext, DragData},
23    geom::{LogicalPosition, PhysicalPositionI32},
24    hit_test::HitTest,
25    task::{Duration as CoreDuration, Instant as CoreInstant},
26    window::WindowPosition,
27};
28use azul_css::{impl_option, impl_option_inner};
29
30
31#[cfg(feature = "std")]
32static NEXT_EVENT_ID: AtomicU64 = AtomicU64::new(1);
33
34/// Allocate a new unique event ID
35#[cfg(feature = "std")]
36pub fn allocate_event_id() -> u64 {
37    NEXT_EVENT_ID.fetch_add(1, Ordering::Relaxed)
38}
39
40/// Allocate a new unique event ID (no_std fallback: returns 0)
41#[cfg(not(feature = "std"))]
42pub fn allocate_event_id() -> u64 {
43    0
44}
45
46/// Helper function to convert `CoreDuration` to milliseconds
47///
48/// `CoreDuration` is an enum with System (`std::time::Duration`) and Tick variants.
49/// We need to handle both cases for proper time calculations.
50#[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
51fn duration_to_millis(duration: CoreDuration) -> u64 {
52    match duration {
53        #[cfg(feature = "std")]
54        CoreDuration::System(system_diff) => {
55            let std_duration: std::time::Duration = system_diff.into();
56            std_duration.as_millis() as u64
57        }
58        #[cfg(not(feature = "std"))]
59        CoreDuration::System(system_diff) => {
60            // Manual calculation: secs * 1000 + nanos / 1_000_000
61            system_diff.secs * 1000 + (system_diff.nanos / 1_000_000) as u64
62        }
63        CoreDuration::Tick(tick_diff) => {
64            // WARNING: assumes 1 tick = 1 ms. This is correct for platforms
65            // that use a millisecond tick counter, but will silently produce
66            // wrong timing on platforms with a different tick resolution.
67            tick_diff.tick_diff
68        }
69    }
70}
71
72/// Maximum number of input samples to keep in memory
73///
74/// This prevents unbounded memory growth during long drags.
75/// Older samples beyond this limit are automatically discarded.
76pub const MAX_SAMPLES_PER_SESSION: usize = 1000;
77
78/// Default timeout for clearing old gesture samples (milliseconds)
79///
80/// Samples older than this are automatically removed to prevent
81/// memory leaks and stale gesture detection.
82pub const DEFAULT_SAMPLE_TIMEOUT_MS: u64 = 2000;
83
84/// Number of samples to drain at once when the session exceeds `MAX_SAMPLES_PER_SESSION`.
85///
86/// Batch draining avoids per-sample overhead on every new sample.
87const DRAIN_BATCH_SIZE: usize = 100;
88
89/// MWA-B4: button-state bitfield recorded for touch-contact samples.
90///
91/// A finger on the surface = primary contact, mirroring `BUTTON_STATE_LEFT` in
92/// the dll's mouse path so drag heuristics treat touch like a held button.
93pub const TOUCH_CONTACT_BUTTON_STATE: u8 = 0x01;
94
95/// Configuration for gesture detection thresholds
96#[derive(Debug, Clone, Copy, PartialEq)]
97pub struct GestureDetectionConfig {
98    /// Minimum distance (pixels) to consider movement a drag, not a click
99    pub drag_distance_threshold: f32,
100    /// Maximum time between clicks for double-click detection (milliseconds)
101    pub double_click_time_threshold_ms: u64,
102    /// Maximum distance between clicks for double-click detection (pixels)
103    pub double_click_distance_threshold: f32,
104    /// Minimum time to hold button for long-press detection (milliseconds)
105    pub long_press_time_threshold_ms: u64,
106    /// Maximum distance to move while holding for long-press (pixels)
107    pub long_press_distance_threshold: f32,
108    /// Minimum samples needed to detect a gesture
109    pub min_samples_for_gesture: usize,
110    /// Minimum velocity for swipe detection (pixels per second)
111    pub swipe_velocity_threshold: f32,
112    /// Minimum scale change for pinch detection (e.g., 0.1 = 10% change)
113    pub pinch_scale_threshold: f32,
114    /// Minimum rotation angle for rotation detection (radians)
115    pub rotation_angle_threshold: f32,
116    /// How often to clear old samples (milliseconds)
117    pub sample_cleanup_interval_ms: u64,
118}
119
120impl Default for GestureDetectionConfig {
121    fn default() -> Self {
122        Self {
123            drag_distance_threshold: 5.0,
124            double_click_time_threshold_ms: 500,
125            double_click_distance_threshold: 5.0,
126            long_press_time_threshold_ms: 500,
127            long_press_distance_threshold: 10.0,
128            min_samples_for_gesture: 2,
129            swipe_velocity_threshold: 500.0, // 500 px/s
130            pinch_scale_threshold: 0.1,      // 10% scale change
131            rotation_angle_threshold: 0.1,   // ~5.7 degrees in radians
132            sample_cleanup_interval_ms: DEFAULT_SAMPLE_TIMEOUT_MS,
133        }
134    }
135}
136
137/// Single input sample with position and timestamp
138#[derive(Debug, Clone, PartialEq)]
139pub struct InputSample {
140    /// Position in logical coordinates (window-local, Y=0 at top of window)
141    pub position: LogicalPosition,
142    /// Position in virtual screen coordinates (Y=0 at top of primary monitor).
143    ///
144    /// Computed as `window_position + position` at the time the sample is recorded.
145    /// This is stable during window drags because `window_pos + cursor_local`
146    /// always equals the true screen position, even when the window moves.
147    ///
148    /// All coordinates are in logical pixels (HiDPI-independent).
149    /// On Wayland, this is an estimate (compositor does not expose global position).
150    pub screen_position: LogicalPosition,
151    /// Timestamp when this sample was recorded (from `ExternalSystemCallbacks`)
152    pub timestamp: CoreInstant,
153    /// Mouse button state (bitfield: 0x01 = left, 0x02 = right, 0x04 = middle)
154    pub button_state: u8,
155    /// Unique, monotonic event ID for ordering (atomic counter)
156    pub event_id: u64,
157    /// Pen/stylus pressure (0.0 to 1.0, 0.5 = default for mouse)
158    pub pressure: f32,
159    /// Pen/stylus tilt angles in degrees (`x_tilt`, `y_tilt`)
160    /// Range: typically -90.0 to 90.0, (0.0, 0.0) = perpendicular
161    pub tilt: (f32, f32),
162    /// Touch contact radius in logical pixels (width, height)
163    /// For mouse input, this is (0.0, 0.0)
164    pub touch_radius: (f32, f32),
165}
166
167impl_option!(
168    InputSample,
169    OptionInputSample,
170    copy = false,
171    [Debug, Clone, PartialEq]
172);
173
174/// A sequence of input samples forming one button press session
175#[derive(Debug, Clone, PartialEq)]
176pub struct InputSession {
177    /// All recorded samples for this session
178    pub samples: Vec<InputSample>,
179    /// Whether this session has ended (button released)
180    pub ended: bool,
181    /// Session ID for tracking (incremental counter)
182    pub session_id: u64,
183    /// Window position at the time this session started (mouse-down).
184    /// Used by titlebar drag callbacks to compute new window position.
185    pub window_position_at_start: WindowPosition,
186}
187
188impl InputSession {
189    /// Create a new input session
190    fn new(session_id: u64, first_sample: InputSample, window_position: WindowPosition) -> Self {
191        Self {
192            samples: vec![first_sample],
193            ended: false,
194            session_id,
195            window_position_at_start: window_position,
196        }
197    }
198
199    /// Get the first sample in this session
200    #[must_use] pub fn first_sample(&self) -> Option<&InputSample> {
201        self.samples.first()
202    }
203
204    /// Get the last sample in this session
205    #[must_use] pub fn last_sample(&self) -> Option<&InputSample> {
206        self.samples.last()
207    }
208
209    /// Get the duration of this session (first to last sample)
210    #[must_use] pub fn duration_ms(&self) -> Option<u64> {
211        let first = self.first_sample()?;
212        let last = self.last_sample()?;
213        let duration = last.timestamp.duration_since(&first.timestamp);
214        Some(duration_to_millis(duration))
215    }
216
217    /// Get the total distance traveled in this session
218    #[must_use] pub fn total_distance(&self) -> f32 {
219        if self.samples.len() < 2 {
220            return 0.0;
221        }
222
223        let mut total = 0.0;
224        for i in 1..self.samples.len() {
225            let prev = &self.samples[i - 1];
226            let curr = &self.samples[i];
227            let dx = curr.position.x - prev.position.x;
228            let dy = curr.position.y - prev.position.y;
229            total += dx.hypot(dy);
230        }
231        total
232    }
233
234    /// Get the straight-line distance from first to last sample
235    #[must_use] pub fn direct_distance(&self) -> Option<f32> {
236        let first = self.first_sample()?;
237        let last = self.last_sample()?;
238        let dx = last.position.x - first.position.x;
239        let dy = last.position.y - first.position.y;
240        Some(dx.hypot(dy))
241    }
242}
243
244/// Result of drag detection analysis
245#[derive(Debug, Clone, Copy, PartialEq)]
246pub struct DetectedDrag {
247    /// Position where drag started
248    pub start_position: LogicalPosition,
249    /// Current/end position of drag
250    pub current_position: LogicalPosition,
251    /// Direct distance dragged (straight line, pixels)
252    pub direct_distance: f32,
253    /// Total distance dragged (following path, pixels)
254    pub total_distance: f32,
255    /// Duration of the drag (milliseconds)
256    pub duration_ms: u64,
257    /// Number of position samples recorded
258    pub sample_count: usize,
259    /// Session ID this drag belongs to
260    pub session_id: u64,
261}
262
263/// Result of long-press detection
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265#[repr(C)]
266pub struct DetectedLongPress {
267    /// Position where long press is happening
268    pub position: LogicalPosition,
269    /// How long the button has been held (milliseconds)
270    pub duration_ms: u64,
271    /// Whether the callback has already been invoked for this long press
272    pub callback_invoked: bool,
273    /// Session ID this long press belongs to
274    pub session_id: u64,
275}
276
277/// Primary direction of a gesture
278#[derive(Debug, Clone, Copy, PartialEq, Eq)]
279#[repr(C)]
280pub enum GestureDirection {
281    Up,
282    Down,
283    Left,
284    Right,
285}
286
287impl_option!(
288    GestureDirection,
289    OptionGestureDirection,
290    [Debug, Clone, Copy, PartialEq, Eq]
291);
292impl_option!(
293    DetectedPinch,
294    OptionDetectedPinch,
295    [Debug, Clone, Copy, PartialEq]
296);
297impl_option!(
298    DetectedRotation,
299    OptionDetectedRotation,
300    [Debug, Clone, Copy, PartialEq]
301);
302impl_option!(
303    DetectedLongPress,
304    OptionDetectedLongPress,
305    [Debug, Clone, Copy, PartialEq, Eq]
306);
307
308/// Result of pinch gesture detection
309#[derive(Debug, Clone, Copy, PartialEq)]
310#[repr(C)]
311pub struct DetectedPinch {
312    /// Scale factor (< 1.0 for pinch in, > 1.0 for pinch out)
313    pub scale: f32,
314    /// Center point of the pinch gesture
315    pub center: LogicalPosition,
316    /// Initial distance between touch points
317    pub initial_distance: f32,
318    /// Current distance between touch points
319    pub current_distance: f32,
320    /// Duration of pinch (milliseconds)
321    pub duration_ms: u64,
322}
323
324/// Result of rotation gesture detection
325#[derive(Debug, Clone, Copy, PartialEq)]
326#[repr(C)]
327pub struct DetectedRotation {
328    /// Rotation angle in radians (positive = clockwise)
329    pub angle_radians: f32,
330    /// Center point of rotation
331    pub center: LogicalPosition,
332    /// Duration of rotation (milliseconds)
333    pub duration_ms: u64,
334}
335
336
337/// State of pen/stylus input
338#[derive(Debug, Clone, Copy, PartialEq)]
339#[repr(C)]
340pub struct PenState {
341    /// Current pen position
342    pub position: LogicalPosition,
343    /// Current pressure (0.0 to 1.0)
344    pub pressure: f32,
345    /// Current tilt angles (`x_tilt`, `y_tilt`) in degrees
346    pub tilt: crate::callbacks::PenTilt,
347    /// Whether pen is in contact with surface
348    pub in_contact: bool,
349    /// Whether pen is inverted (eraser mode)
350    pub is_eraser: bool,
351    /// Whether barrel button is pressed
352    pub barrel_button_pressed: bool,
353    /// Unique identifier for this pen device
354    pub device_id: u64,
355    /// Tangential / cylinder pressure (0.0 to 1.0). Wacom Air Brush wheel,
356    /// Surface Slim Pen 2 secondary axis. `0.0` means "not reported".
357    /// Maps to W3C `PointerEvent.tangentialPressure`.
358    pub tangential_pressure: f32,
359    /// Barrel roll angle in radians (–π to π). Wacom Art Pen rotation,
360    /// Surface Pen barrel-roll axis. `0.0` means "not reported" (devices
361    /// that do report it sweep through the full range as the user rolls
362    /// the pen — the resting state isn't necessarily zero, so callers
363    /// should compare deltas, not absolute values).
364    /// Maps to W3C `PointerEvent.twist` (in radians, not degrees).
365    pub barrel_roll_rad: f32,
366    /// Per-tool identity for hand-held pens that report it (Wintab GUID,
367    /// Apple Pencil session id, S-Pen serial). `0` means "not reported".
368    /// Distinct from `device_id` so callers can both identify the
369    /// hardware (`device_id`) *and* which tip / lead / button cluster is
370    /// in use (`tool_id`).
371    pub tool_id: u32,
372}
373
374impl_option!(PenState, OptionPenState, [Debug, Clone, Copy, PartialEq]);
375
376impl Default for PenState {
377    fn default() -> Self {
378        Self {
379            position: LogicalPosition::zero(),
380            pressure: 0.0,
381            tilt: crate::callbacks::PenTilt {
382                x_tilt: 0.0,
383                y_tilt: 0.0,
384            },
385            in_contact: false,
386            is_eraser: false,
387            barrel_button_pressed: false,
388            device_id: 0,
389            tangential_pressure: 0.0,
390            barrel_roll_rad: 0.0,
391            tool_id: 0,
392        }
393    }
394}
395
396/// State of a Wacom-style tablet **pad** — the tablet body's own hardware
397/// controls, distinct from the pen ([`PenState`] already covers eraser /
398/// barrel button / barrel roll / tilt / pressure).
399///
400/// Populated by the platform
401/// backend (`dll/src/desktop/extra/wacom_pad/`: Wintab on Windows,
402/// libwacom+libinput on Linux, the driver's `NSEvent` tablet events on macOS).
403#[derive(Debug, Clone, Copy, PartialEq)]
404#[repr(C)]
405pub struct WacomPadState {
406    /// `ExpressKey` bitset — bit `n` set ⇔ hardware button `n` is held (up to
407    /// 32). Read via [`WacomPadState::express_key`].
408    pub express_keys: u32,
409    /// Touch-ring / touch-strip absolute position, `0.0`–`1.0`. Only
410    /// meaningful while [`WacomPadState::touch_ring_active`] is `true`.
411    pub touch_ring: f32,
412    /// Whether a finger is currently on the touch-ring / touch-strip.
413    pub touch_ring_active: bool,
414    /// Tablet device id (to distinguish pads on multi-tablet setups).
415    pub device_id: u64,
416}
417
418impl_option!(
419    WacomPadState,
420    OptionWacomPadState,
421    [Debug, Clone, Copy, PartialEq]
422);
423
424impl Default for WacomPadState {
425    fn default() -> Self {
426        Self {
427            express_keys: 0,
428            touch_ring: 0.0,
429            touch_ring_active: false,
430            device_id: 0,
431        }
432    }
433}
434
435impl WacomPadState {
436    /// Whether `ExpressKey` `index` (0-based, < 32) is currently held.
437    #[must_use] pub const fn express_key(&self, index: u32) -> bool {
438        index < 32 && (self.express_keys & (1u32 << index)) != 0
439    }
440}
441
442/// Manager for multi-frame gestures and drag operations
443///
444/// This collects raw input samples and analyzes them to detect gestures.
445/// Designed for testability and clear separation of input collection
446/// vs. detection.
447///
448/// ## Unified Drag System
449///
450/// The manager now uses `DragContext` to unify all drag types:
451/// - `active_drag`: The unified drag context (replaces individual drag states)
452///
453/// For backwards compatibility, the old `node_drag`, `window_drag`, `file_drop`
454/// fields are still accessible but deprecated.
455#[derive(Debug, Clone, PartialEq)]
456pub struct GestureAndDragManager {
457    /// Configuration for gesture detection
458    pub config: GestureDetectionConfig,
459    /// All recorded input sessions (multiple button press sequences)
460    pub input_sessions: Vec<InputSession>,
461    /// **NEW**: Unified drag context for all drag types
462    pub active_drag: Option<DragContext>,
463    /// Current pen/stylus state
464    pub pen_state: Option<PenState>,
465    /// Pen state as of the previous determine-events pass (for diffing pen events).
466    pub previous_pen_state: Option<PenState>,
467    /// Set when pen state changed; gates one pen-event diff (cleared by the event loop).
468    pub pen_event_pending: bool,
469    /// Latest Wacom tablet-pad state (`ExpressKeys` + touch-ring), or `None`
470    /// until a pad backend delivers one.
471    pub pad_state: Option<WacomPadState>,
472    /// Session IDs where long press callback has been invoked
473    long_press_callbacks_invoked: Vec<u64>,
474    /// Counter for generating unique session IDs
475    next_session_id: u64,
476    /// Native-platform gesture override slot.
477    ///
478    /// Platforms with first-class gesture recognizers (iOS `UIKit`,
479    /// Android `GestureDetector` + `ScaleGestureDetector`, macOS
480    /// `NSGestureRecognizer`) inject pre-detected gestures here via
481    /// [`GestureAndDragManager::inject_native_gesture`]. The
482    /// `detect_*` methods consult this slot before running their
483    /// in-process heuristics, so callbacks observe consistent results
484    /// regardless of the detection source.
485    ///
486    /// Cleared automatically at the start of every new input recording
487    /// cycle so a single OS event doesn't keep firing.
488    pub native_gesture: Option<NativeGestureEvent>,
489    /// MWA-B4: OS touch id → session id. Desktop touch events previously
490    /// only filled the window's `touch_state`, so no touch ever became an
491    /// input session and `detect_pinch` / `detect_rotation` (which need two
492    /// concurrent sessions) were structurally dead on Windows/X11/Wayland.
493    /// The shells call [`touch_down`](Self::touch_down) /
494    /// [`touch_move`](Self::touch_move) / [`touch_up`](Self::touch_up); each
495    /// finger gets its own session (two fingers = two live sessions).
496    touch_sessions: alloc::collections::btree_map::BTreeMap<u64, u64>,
497}
498
499/// Gesture detected by a platform-native recognizer.
500///
501/// Platform backends construct one of these in their gesture-recognizer
502/// callbacks (iOS `UIKit`, Android `GestureDetector`, macOS
503/// `NSGestureRecognizer`) and hand it to
504/// [`GestureAndDragManager::inject_native_gesture`]. The in-process
505/// `detect_*` methods then return the native result, sidestepping their
506/// fallback heuristics. On platforms with poor native gesture support
507/// (X11 / Wayland touch, headless), backends never inject and the
508/// in-process detector remains authoritative.
509#[derive(Debug, Clone, Copy, PartialEq)]
510#[repr(C, u8)]
511pub enum NativeGestureEvent {
512    /// Single tap / double-click detected natively.
513    DoubleClick,
514    /// Long-press detected natively (iOS `UILongPressGestureRecognizer`,
515    /// Android `GestureDetector.OnGestureListener::onLongPress`).
516    LongPress(DetectedLongPress),
517    /// Swipe detected natively (iOS `UISwipeGestureRecognizer`,
518    /// Android `GestureDetector.OnGestureListener::onFling`).
519    Swipe(GestureDirection),
520    /// Pinch detected natively (iOS `UIPinchGestureRecognizer`,
521    /// Android `ScaleGestureDetector`, macOS magnification gesture).
522    Pinch(DetectedPinch),
523    /// Rotation detected natively (iOS `UIRotationGestureRecognizer`,
524    /// macOS rotation gesture).
525    Rotation(DetectedRotation),
526}
527
528
529impl Default for GestureAndDragManager {
530    fn default() -> Self {
531        Self::new()
532    }
533}
534
535impl GestureAndDragManager {
536    /// (`input_sessions`, `long_press_callbacks_invoked`). Used by
537    /// `AZ_E2E_TEST` to watch for unbounded growth.
538    #[must_use] pub const fn debug_counts(&self) -> (usize, usize) {
539        (self.input_sessions.len(), self.long_press_callbacks_invoked.len())
540    }
541
542    /// Create a new gesture and drag manager
543    #[must_use] pub fn new() -> Self {
544        Self {
545            config: GestureDetectionConfig::default(),
546            input_sessions: Vec::new(),
547            next_session_id: 1,
548            active_drag: None,
549            pen_state: None,
550            previous_pen_state: None,
551            pen_event_pending: false,
552            pad_state: None,
553            long_press_callbacks_invoked: Vec::new(),
554            native_gesture: None,
555            touch_sessions: alloc::collections::btree_map::BTreeMap::new(),
556        }
557    }
558
559    /// Inject a native gesture-recognizer result, overriding the
560    /// in-process detector for the current event frame. Called by the
561    /// iOS / Android / macOS platform backend from their gesture
562    /// recognizer callbacks. The override is read once by the next
563    /// `detect_*` call.
564    pub const fn inject_native_gesture(&mut self, gesture: NativeGestureEvent) {
565        self.native_gesture = Some(gesture);
566    }
567
568    /// Clear any pending native-gesture override. Called by the event
569    /// loop after each frame's detections have been consumed so a
570    /// stale OS gesture doesn't keep firing.
571    pub const fn clear_native_gesture(&mut self) {
572        self.native_gesture = None;
573    }
574
575    /// Create with custom configuration
576    #[must_use] pub fn with_config(config: GestureDetectionConfig) -> Self {
577        Self {
578            config,
579            ..Self::new()
580        }
581    }
582
583    // Input Recording Methods (called from event loop / system timer)
584
585    /// Start a new input session (mouse button pressed down)
586    ///
587    /// This begins recording samples for gesture detection.
588    /// Call this when receiving mouse button down event.
589    ///
590    /// `window_position` is the current OS window position at the time of mouse-down.
591    /// It is stored so that drag callbacks can compute the new window position.
592    ///
593    /// Returns the session ID for this new session.
594    pub fn start_input_session(
595        &mut self,
596        position: LogicalPosition,
597        timestamp: CoreInstant,
598        button_state: u8,
599        window_position: WindowPosition,
600        screen_position: LogicalPosition,
601    ) -> u64 {
602        self.start_input_session_with_pen(
603            position,
604            timestamp,
605            button_state,
606            allocate_event_id(),
607            0.5,        // default pressure for mouse
608            (0.0, 0.0), // no tilt for mouse
609            (0.0, 0.0), // no touch radius for mouse
610            window_position,
611            screen_position,
612        )
613    }
614
615    /// Start a new input session with pen/touch data
616    pub fn start_input_session_with_pen(
617        &mut self,
618        position: LogicalPosition,
619        timestamp: CoreInstant,
620        button_state: u8,
621        event_id: u64,
622        pressure: f32,
623        tilt: (f32, f32),
624        touch_radius: (f32, f32),
625        window_position: WindowPosition,
626        screen_position: LogicalPosition,
627    ) -> u64 {
628        // Clear old ended sessions, but keep the most recent ended session
629        // for double-click detection. detect_double_click() needs two ended
630        // sessions to compare timing and distance.
631        let last_ended_idx = self.input_sessions.iter().rposition(|s| s.ended);
632        let mut idx = 0usize;
633        self.input_sessions.retain(|session| {
634            let keep = !session.ended || Some(idx) == last_ended_idx;
635            idx += 1;
636            keep
637        });
638
639        let session_id = self.next_session_id;
640        self.next_session_id += 1;
641
642        let sample = InputSample {
643            position,
644            screen_position,
645            timestamp,
646            button_state,
647            event_id,
648            pressure,
649            tilt,
650            touch_radius,
651        };
652
653        let session = InputSession::new(session_id, sample, window_position);
654        self.input_sessions.push(session);
655
656        session_id
657    }
658
659    /// Record an input sample to the current session
660    ///
661    /// Call this on every mouse move event while button is pressed,
662    /// and also periodically from a system timer to track long presses.
663    ///
664    /// Returns true if sample was recorded, false if no active session.
665    pub fn record_input_sample(
666        &mut self,
667        position: LogicalPosition,
668        timestamp: CoreInstant,
669        button_state: u8,
670        screen_position: LogicalPosition,
671    ) -> bool {
672        self.record_input_sample_with_pen(
673            position,
674            timestamp,
675            button_state,
676            allocate_event_id(),
677            0.5,        // default pressure for mouse
678            (0.0, 0.0), // no tilt for mouse
679            (0.0, 0.0), // no touch radius for mouse
680            screen_position,
681        )
682    }
683
684    /// Record an input sample with pen/touch data
685    pub fn record_input_sample_with_pen(
686        &mut self,
687        position: LogicalPosition,
688        timestamp: CoreInstant,
689        button_state: u8,
690        event_id: u64,
691        pressure: f32,
692        tilt: (f32, f32),
693        touch_radius: (f32, f32),
694        screen_position: LogicalPosition,
695    ) -> bool {
696        let Some(session) = self.input_sessions.last_mut() else {
697            return false;
698        };
699
700        if session.ended {
701            return false;
702        }
703
704        // Enforce max samples limit
705        if session.samples.len() >= MAX_SAMPLES_PER_SESSION {
706            // Remove oldest samples, keeping the most recent ones
707            let remove_count = session.samples.len() - MAX_SAMPLES_PER_SESSION + DRAIN_BATCH_SIZE;
708            session.samples.drain(0..remove_count);
709        }
710
711        session.samples.push(InputSample {
712            position,
713            screen_position,
714            timestamp,
715            button_state,
716            event_id,
717            pressure,
718            tilt,
719            touch_radius,
720        });
721
722        true
723    }
724
725    /// End the current input session (mouse button released)
726    ///
727    /// Call this when receiving mouse button up event.
728    /// The session is kept for analysis but marked as ended.
729    pub fn end_current_session(&mut self) {
730        if let Some(session) = self.input_sessions.last_mut() {
731            session.ended = true;
732        }
733    }
734
735    // --- Per-touch-id input sessions (MWA-B4) ---
736
737    /// A finger made contact: open a dedicated session for `touch_id`.
738    pub fn touch_down(
739        &mut self,
740        touch_id: u64,
741        position: LogicalPosition,
742        timestamp: CoreInstant,
743        window_position: WindowPosition,
744        screen_position: LogicalPosition,
745    ) {
746        let session_id = self.start_input_session(
747            position,
748            timestamp,
749            TOUCH_CONTACT_BUTTON_STATE,
750            window_position,
751            screen_position,
752        );
753        self.touch_sessions.insert(touch_id, session_id);
754    }
755
756    /// A finger moved: record into ITS OWN session — never `last_mut()`,
757    /// two concurrent fingers must not interleave into one session (that
758    /// would corrupt both the drag heuristics and pinch/rotate distances).
759    /// Returns `true` if a sample was recorded.
760    pub fn touch_move(
761        &mut self,
762        touch_id: u64,
763        position: LogicalPosition,
764        timestamp: CoreInstant,
765        screen_position: LogicalPosition,
766    ) -> bool {
767        let Some(session_id) = self.touch_sessions.get(&touch_id).copied() else {
768            return false;
769        };
770        self.record_sample_for_session(session_id, position, timestamp, screen_position)
771    }
772
773    /// A finger lifted (or the OS cancelled the touch): final sample + end
774    /// the session and drop the id mapping.
775    pub fn touch_up(
776        &mut self,
777        touch_id: u64,
778        position: LogicalPosition,
779        timestamp: CoreInstant,
780        screen_position: LogicalPosition,
781    ) {
782        let Some(session_id) = self.touch_sessions.remove(&touch_id) else {
783            return;
784        };
785        let _ = self.record_sample_for_session(session_id, position, timestamp, screen_position);
786        if let Some(session) = self
787            .input_sessions
788            .iter_mut()
789            .find(|s| s.session_id == session_id)
790        {
791            session.ended = true;
792        }
793    }
794
795    /// The OS cancelled the whole touch sequence (e.g. the compositor took
796    /// the gesture over): end every touch session and drop the id map.
797    pub fn touch_cancel_all(&mut self) {
798        let ids: Vec<u64> = self.touch_sessions.values().copied().collect();
799        self.touch_sessions.clear();
800        for session_id in ids {
801            if let Some(session) = self
802                .input_sessions
803                .iter_mut()
804                .find(|s| s.session_id == session_id)
805            {
806                session.ended = true;
807            }
808        }
809    }
810
811    /// Record a sample into the session with `session_id` (MWA-B4 helper —
812    /// the by-id sibling of `record_input_sample_with_pen`, which only ever
813    /// writes to the LAST session).
814    fn record_sample_for_session(
815        &mut self,
816        session_id: u64,
817        position: LogicalPosition,
818        timestamp: CoreInstant,
819        screen_position: LogicalPosition,
820    ) -> bool {
821        let Some(session) = self
822            .input_sessions
823            .iter_mut()
824            .find(|s| s.session_id == session_id)
825        else {
826            return false;
827        };
828        if session.ended {
829            return false;
830        }
831        if session.samples.len() >= MAX_SAMPLES_PER_SESSION {
832            let remove_count =
833                session.samples.len() - MAX_SAMPLES_PER_SESSION + DRAIN_BATCH_SIZE;
834            session.samples.drain(0..remove_count);
835        }
836        session.samples.push(InputSample {
837            position,
838            screen_position,
839            timestamp,
840            button_state: TOUCH_CONTACT_BUTTON_STATE,
841            event_id: allocate_event_id(),
842            pressure: 0.5,
843            tilt: (0.0, 0.0),
844            touch_radius: (0.0, 0.0),
845        });
846        true
847    }
848
849    /// Clear old input sessions that have timed out
850    ///
851    /// Call this periodically (e.g., every frame) to prevent memory leaks.
852    /// Sessions older than `config.sample_cleanup_interval_ms` are removed.
853    // CoreInstant is a ref-counted FFI clock handle threaded through the event loop by value;
854    // &-converting would cascade through the loop call chain and across all dll backends.
855    #[allow(clippy::needless_pass_by_value)]
856    pub fn clear_old_sessions(&mut self, current_time: CoreInstant) {
857        self.input_sessions.retain(|session| {
858            if let Some(last_sample) = session.last_sample() {
859                let duration = current_time.duration_since(&last_sample.timestamp);
860                let age_ms = duration_to_millis(duration);
861                age_ms < self.config.sample_cleanup_interval_ms
862            } else {
863                false
864            }
865        });
866
867        // Also clear long press callback tracking for removed sessions
868        let valid_session_ids: Vec<u64> =
869            self.input_sessions.iter().map(|s| s.session_id).collect();
870
871        self.long_press_callbacks_invoked
872            .retain(|id| valid_session_ids.contains(id));
873    }
874
875    /// Clear all input sessions
876    ///
877    /// Call this when you want to reset all gesture detection state.
878    pub fn clear_all_sessions(&mut self) {
879        self.input_sessions.clear();
880        self.long_press_callbacks_invoked.clear();
881    }
882
883    /// Update pen/stylus state
884    ///
885    /// Call this when receiving pen events from the platform. The
886    /// extended fields (`tangential_pressure`, `barrel_roll_rad`,
887    /// `tool_id`) default to `0` — pass [`update_pen_state_full`] when
888    /// the platform reports them.
889    pub const fn update_pen_state(
890        &mut self,
891        position: LogicalPosition,
892        pressure: f32,
893        tilt: (f32, f32),
894        in_contact: bool,
895        is_eraser: bool,
896        barrel_button_pressed: bool,
897        device_id: u64,
898    ) {
899        self.update_pen_state_full(
900            position,
901            pressure,
902            tilt,
903            in_contact,
904            is_eraser,
905            barrel_button_pressed,
906            device_id,
907            0.0,
908            0.0,
909            0,
910        );
911    }
912
913    /// Update pen/stylus state including the extended axes (W3C
914    /// `PointerEvent.tangentialPressure` + `twist`) and per-tool id.
915    pub const fn update_pen_state_full(
916        &mut self,
917        position: LogicalPosition,
918        pressure: f32,
919        tilt: (f32, f32),
920        in_contact: bool,
921        is_eraser: bool,
922        barrel_button_pressed: bool,
923        device_id: u64,
924        tangential_pressure: f32,
925        barrel_roll_rad: f32,
926        tool_id: u32,
927    ) {
928        self.previous_pen_state = self.pen_state;
929        self.pen_state = Some(PenState {
930            position,
931            pressure,
932            tilt: crate::callbacks::PenTilt {
933                x_tilt: tilt.0,
934                y_tilt: tilt.1,
935            },
936            in_contact,
937            is_eraser,
938            barrel_button_pressed,
939            device_id,
940            tangential_pressure,
941            barrel_roll_rad,
942            tool_id,
943        });
944        self.pen_event_pending = true;
945    }
946
947    /// Clear pen state (when pen leaves proximity)
948    pub const fn clear_pen_state(&mut self) {
949        self.previous_pen_state = self.pen_state;
950        self.pen_state = None;
951        self.pen_event_pending = true;
952    }
953
954    /// Get current pen state (read-only)
955    #[must_use] pub const fn get_pen_state(&self) -> Option<&PenState> {
956        self.pen_state.as_ref()
957    }
958
959    /// Get the previous pen state (for event diffing).
960    #[must_use] pub const fn get_previous_pen_state(&self) -> Option<&PenState> {
961        self.previous_pen_state.as_ref()
962    }
963
964    /// Clear the pen-event-pending flag (called by the event loop after a pass).
965    pub const fn clear_pen_event_pending(&mut self) {
966        self.pen_event_pending = false;
967    }
968
969    /// Set the latest Wacom tablet-pad state (called by the pad backend).
970    pub const fn update_pad_state(&mut self, pad: WacomPadState) {
971        self.pad_state = Some(pad);
972    }
973
974    /// The latest tablet-pad state, or `None` if no pad backend delivered one.
975    #[must_use] pub const fn get_pad_state(&self) -> Option<&WacomPadState> {
976        self.pad_state.as_ref()
977    }
978
979    /// Clear the tablet-pad state (pad disconnected / proximity left).
980    pub const fn clear_pad_state(&mut self) {
981        self.pad_state = None;
982    }
983
984    // Gesture Detection Methods (query state without mutation)
985
986    /// Detect if current input represents a drag gesture
987    ///
988    /// Returns Some(DetectedDrag) if a drag is detected based on distance threshold.
989    #[must_use] pub fn detect_drag(&self) -> Option<DetectedDrag> {
990        let session = self.get_current_session()?;
991
992        if session.samples.len() < self.config.min_samples_for_gesture {
993            return None;
994        }
995
996        let direct_distance = session.direct_distance()?;
997
998        if direct_distance >= self.config.drag_distance_threshold {
999            let first = session.first_sample()?;
1000            let last = session.last_sample()?;
1001
1002            Some(DetectedDrag {
1003                start_position: first.position,
1004                current_position: last.position,
1005                direct_distance,
1006                total_distance: session.total_distance(),
1007                duration_ms: session.duration_ms()?,
1008                sample_count: session.samples.len(),
1009                session_id: session.session_id,
1010            })
1011        } else {
1012            None
1013        }
1014    }
1015
1016    /// Detect if current input represents a long press
1017    ///
1018    /// Returns Some(DetectedLongPress) if button has been held long enough
1019    /// without moving much.
1020    #[must_use] pub fn detect_long_press(&self) -> Option<DetectedLongPress> {
1021        if let Some(NativeGestureEvent::LongPress(lp)) = self.native_gesture {
1022            return Some(lp);
1023        }
1024        let session = self.get_current_session()?;
1025
1026        if session.ended {
1027            return None; // Can't be long press if button already released
1028        }
1029
1030        let duration_ms = session.duration_ms()?;
1031
1032        if duration_ms < self.config.long_press_time_threshold_ms {
1033            return None;
1034        }
1035
1036        let distance = session.direct_distance()?;
1037
1038        if distance <= self.config.long_press_distance_threshold {
1039            let first = session.first_sample()?;
1040            let callback_invoked = self
1041                .long_press_callbacks_invoked
1042                .contains(&session.session_id);
1043
1044            Some(DetectedLongPress {
1045                position: first.position,
1046                duration_ms,
1047                callback_invoked,
1048                session_id: session.session_id,
1049            })
1050        } else {
1051            None
1052        }
1053    }
1054
1055    /// Mark long press callback as invoked for a session
1056    ///
1057    /// Call this after invoking the long press callback to prevent
1058    /// repeated invocations.
1059    /// MWA-B12: mark the CURRENT session's long-press as delivered. The
1060    /// event pass calls this right after emitting `EventType::LongPress` —
1061    /// nothing ever called `mark_long_press_callback_invoked`, so `LongPress`
1062    /// re-fired on every subsequent pass of the same hold.
1063    pub fn mark_current_long_press_invoked(&mut self) {
1064        if let Some(id) = self.get_current_session().map(|s| s.session_id) {
1065            self.mark_long_press_callback_invoked(id);
1066        }
1067    }
1068
1069    pub fn mark_long_press_callback_invoked(&mut self, session_id: u64) {
1070        if !self.long_press_callbacks_invoked.contains(&session_id) {
1071            self.long_press_callbacks_invoked.push(session_id);
1072        }
1073    }
1074
1075    /// Detect if last two sessions form a double-click.
1076    ///
1077    /// Returns true if timing and distance match double-click criteria.
1078    #[must_use] pub fn detect_double_click(&self) -> bool {
1079        if matches!(self.native_gesture, Some(NativeGestureEvent::DoubleClick)) {
1080            return true;
1081        }
1082        let sessions = &self.input_sessions;
1083        if sessions.len() < 2 {
1084            return false;
1085        }
1086
1087        let prev_session = &sessions[sessions.len() - 2];
1088        let last_session = &sessions[sessions.len() - 1];
1089
1090        // Both sessions must have ended (button released)
1091        if !prev_session.ended || !last_session.ended {
1092            return false;
1093        }
1094
1095        let prev_first = prev_session.first_sample();
1096        let last_first = last_session.first_sample();
1097        let (Some(prev_first), Some(last_first)) = (prev_first, last_first) else {
1098            return false;
1099        };
1100
1101        let duration = last_first.timestamp.duration_since(&prev_first.timestamp);
1102        let time_delta_ms = duration_to_millis(duration);
1103        if time_delta_ms > self.config.double_click_time_threshold_ms {
1104            return false;
1105        }
1106
1107        let dx = last_first.position.x - prev_first.position.x;
1108        let dy = last_first.position.y - prev_first.position.y;
1109        let distance = dx.hypot(dy);
1110
1111        distance < self.config.double_click_distance_threshold
1112    }
1113
1114    /// Detect click count (1=single, 2=double, 3=triple) by examining
1115    /// the recent ended sessions.  Uses only timestamps and positions
1116    /// from the session history, so the result is fully deterministic
1117    /// for any given sequence of `InputSession`s (easy to unit-test
1118    /// with synthetic `CoreInstant`/`CoreDuration` values).
1119    #[must_use] pub fn detect_click_count(&self) -> u32 {
1120        let sessions = &self.input_sessions;
1121        let n = sessions.len();
1122        if n == 0 {
1123            return 1;
1124        }
1125
1126        // We need at least 2 ended sessions for double-click,
1127        // 3 ended sessions for triple-click.
1128        // Walk backwards from the most recent ended session and count
1129        // how many consecutive clicks fall within the time+distance
1130        // thresholds.
1131
1132        // Collect the last up-to-3 ended sessions (most-recent first).
1133        let mut recent: Vec<&InputSession> = Vec::new();
1134        for s in sessions.iter().rev() {
1135            if !s.ended {
1136                continue;
1137            }
1138            recent.push(s);
1139            if recent.len() >= 3 {
1140                break;
1141            }
1142        }
1143
1144        if recent.is_empty() {
1145            return 1;
1146        }
1147
1148        // recent[0] = most recent ended session
1149        // recent[1] = previous ended session (if any)
1150        // recent[2] = one before that (if any)
1151        let mut count = 1u32;
1152
1153        for i in 0..recent.len() - 1 {
1154            let later = recent[i];
1155            let earlier = recent[i + 1];
1156
1157            let Some(later_start) = later.first_sample() else {
1158                break;
1159            };
1160            let Some(earlier_start) = earlier.first_sample() else {
1161                break;
1162            };
1163
1164            let duration = later_start.timestamp.duration_since(&earlier_start.timestamp);
1165            let time_delta_ms = duration_to_millis(duration);
1166            if time_delta_ms > self.config.double_click_time_threshold_ms {
1167                break;
1168            }
1169
1170            let dx = later_start.position.x - earlier_start.position.x;
1171            let dy = later_start.position.y - earlier_start.position.y;
1172            let distance = dx.hypot(dy);
1173            if distance >= self.config.double_click_distance_threshold {
1174                break;
1175            }
1176
1177            count += 1;
1178        }
1179
1180        // Cap at 3 (triple-click selects paragraph, beyond that cycles back)
1181        if count > 3 { 1 } else { count }
1182    }
1183
1184    /// Get the primary direction of current drag.
1185    #[must_use] pub fn get_drag_direction(&self) -> Option<GestureDirection> {
1186        let session = self.get_current_session()?;
1187        let first = session.first_sample()?;
1188        let last = session.last_sample()?;
1189
1190        let dx = last.position.x - first.position.x;
1191        let dy = last.position.y - first.position.y;
1192
1193        let direction = match (dx.abs() > dy.abs(), dx > 0.0, dy > 0.0) {
1194            (true, true, _) => GestureDirection::Right,
1195            (true, false, _) => GestureDirection::Left,
1196            (false, _, true) => GestureDirection::Down,
1197            (false, _, false) => GestureDirection::Up,
1198        };
1199        Some(direction)
1200    }
1201
1202    /// Get average velocity of current gesture (pixels per second)
1203    #[allow(clippy::cast_precision_loss)] // bounded layout/render numeric cast
1204    #[must_use] pub fn get_gesture_velocity(&self) -> Option<f32> {
1205        let session = self.get_current_session()?;
1206
1207        if session.samples.len() < 2 {
1208            return None;
1209        }
1210
1211        let total_distance = session.total_distance();
1212        let duration_ms = session.duration_ms()?;
1213
1214        if duration_ms == 0 {
1215            return None;
1216        }
1217
1218        let duration_secs = duration_ms as f32 / 1000.0;
1219        Some(total_distance / duration_secs)
1220    }
1221
1222    /// Check if current gesture is a swipe (fast directional movement).
1223    #[must_use] pub fn is_swipe(&self) -> bool {
1224        self.get_gesture_velocity()
1225            .is_some_and(|v| v >= self.config.swipe_velocity_threshold)
1226    }
1227
1228    /// Detect swipe with specific direction
1229    ///
1230    /// Returns Some(dir) if gesture is a fast swipe in a clear direction
1231    #[must_use] pub fn detect_swipe_direction(&self) -> Option<GestureDirection> {
1232        if let Some(NativeGestureEvent::Swipe(d)) = self.native_gesture {
1233            return Some(d);
1234        }
1235        // Must be a fast swipe first
1236        if !self.is_swipe() {
1237            return None;
1238        }
1239
1240        // Get direction
1241        self.get_drag_direction()
1242    }
1243
1244    /// Detect pinch gesture (two-touch zoom in/out)
1245    ///
1246    /// Returns Some if two touch points are active and distance is changing
1247    /// significantly. Scale < 1.0 = pinch in, scale > 1.0 = pinch out.
1248    #[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
1249    #[must_use] pub fn detect_pinch(&self) -> Option<DetectedPinch> {
1250        if let Some(NativeGestureEvent::Pinch(p)) = self.native_gesture {
1251            return Some(p);
1252        }
1253        // Need at least two active sessions for pinch
1254        if self.input_sessions.len() < 2 {
1255            return None;
1256        }
1257
1258        // Get last two sessions (most recent touches)
1259        let session1 = &self.input_sessions[self.input_sessions.len() - 2];
1260        let session2 = &self.input_sessions[self.input_sessions.len() - 1];
1261
1262        // A pinch is a TWO-finger gesture: both contacts must be concurrently
1263        // active. A desktop mouse produces *sequential* sessions (the previous one
1264        // is `ended` on button-up before the next begins), so without this guard a
1265        // stale ended session (e.g. a prior click on a button) pairs with the
1266        // current drag and is misread as a pinch — the map zooms on a plain click.
1267        if session1.ended || session2.ended {
1268            return None;
1269        }
1270
1271        // Both must have samples
1272        let first1 = session1.first_sample()?;
1273        let first2 = session2.first_sample()?;
1274        let last1 = session1.last_sample()?;
1275        let last2 = session2.last_sample()?;
1276
1277        // Calculate initial distance between touches
1278        let dx_initial = first2.position.x - first1.position.x;
1279        let dy_initial = first2.position.y - first1.position.y;
1280        let initial_distance = dx_initial.hypot(dy_initial);
1281
1282        // Calculate current distance
1283        let dx_current = last2.position.x - last1.position.x;
1284        let dy_current = last2.position.y - last1.position.y;
1285        let current_distance = dx_current.hypot(dy_current);
1286
1287        // Avoid division by zero
1288        if initial_distance < 1.0 {
1289            return None;
1290        }
1291
1292        // Calculate scale factor
1293        let scale = current_distance / initial_distance;
1294
1295        // Check if scale change is significant (threshold from config)
1296        let scale_threshold = 1.0 + self.config.pinch_scale_threshold;
1297        if scale > 1.0 / scale_threshold && scale < scale_threshold {
1298            return None; // Change too small
1299        }
1300
1301        // Calculate center point
1302        let center = LogicalPosition {
1303            x: f32::midpoint(last1.position.x, last2.position.x),
1304            y: f32::midpoint(last1.position.y, last2.position.y),
1305        };
1306
1307        // Calculate duration
1308        let duration = last1.timestamp.duration_since(&first1.timestamp);
1309        let duration_ms = duration_to_millis(duration);
1310
1311        Some(DetectedPinch {
1312            scale,
1313            center,
1314            initial_distance,
1315            current_distance,
1316            duration_ms,
1317        })
1318    }
1319
1320    /// Detect rotation gesture (two-touch rotate)
1321    ///
1322    /// Returns Some if two touch points are rotating around center.
1323    /// Positive angle = clockwise, negative = counterclockwise.
1324    #[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
1325    #[must_use] pub fn detect_rotation(&self) -> Option<DetectedRotation> {
1326        const PI: f32 = core::f32::consts::PI;
1327        if let Some(NativeGestureEvent::Rotation(r)) = self.native_gesture {
1328            return Some(r);
1329        }
1330        // Need at least two active sessions
1331        if self.input_sessions.len() < 2 {
1332            return None;
1333        }
1334
1335        // Get last two sessions
1336        let session1 = &self.input_sessions[self.input_sessions.len() - 2];
1337        let session2 = &self.input_sessions[self.input_sessions.len() - 1];
1338
1339        // Two-finger rotation requires both contacts concurrently active; a desktop
1340        // mouse yields sequential sessions, so a stale ended session must not pair
1341        // with the current one (see detect_pinch).
1342        if session1.ended || session2.ended {
1343            return None;
1344        }
1345
1346        // Both must have samples
1347        let first1 = session1.first_sample()?;
1348        let first2 = session2.first_sample()?;
1349        let last1 = session1.last_sample()?;
1350        let last2 = session2.last_sample()?;
1351
1352        // Calculate center (average of both touches)
1353        let center = LogicalPosition {
1354            x: f32::midpoint(last1.position.x, last2.position.x),
1355            y: f32::midpoint(last1.position.y, last2.position.y),
1356        };
1357
1358        // Calculate initial angle between touches
1359        let dx_initial = first2.position.x - first1.position.x;
1360        let dy_initial = first2.position.y - first1.position.y;
1361        let initial_angle = dy_initial.atan2(dx_initial);
1362
1363        // Calculate current angle
1364        let dx_current = last2.position.x - last1.position.x;
1365        let dy_current = last2.position.y - last1.position.y;
1366        let current_angle = dy_current.atan2(dx_current);
1367
1368        // Calculate angle difference (normalized to -π to π)
1369        let mut angle_diff = current_angle - initial_angle;
1370
1371        // Normalize angle to -π to π range
1372        #[allow(clippy::while_float)] // intentional bounded float loop (angle-wrap / pixel-step); an integer counter would be artificial
1373        while angle_diff > PI {
1374            angle_diff -= 2.0 * PI;
1375        }
1376        #[allow(clippy::while_float)] // intentional bounded float loop (angle-wrap / pixel-step); an integer counter would be artificial
1377        while angle_diff < -PI {
1378            angle_diff += 2.0 * PI;
1379        }
1380
1381        // Check if rotation is significant (threshold from config)
1382        if angle_diff.abs() < self.config.rotation_angle_threshold {
1383            return None;
1384        }
1385
1386        // Calculate duration
1387        let duration = last1.timestamp.duration_since(&first1.timestamp);
1388        let duration_ms = duration_to_millis(duration);
1389
1390        Some(DetectedRotation {
1391            angle_radians: angle_diff,
1392            center,
1393            duration_ms,
1394        })
1395    }
1396
1397    /// Get the current active input session (if any)
1398    #[must_use] pub fn get_current_session(&self) -> Option<&InputSession> {
1399        self.input_sessions.last()
1400    }
1401
1402    /// Get current mouse position from latest sample
1403    #[must_use] pub fn get_current_mouse_position(&self) -> Option<LogicalPosition> {
1404        self.get_current_session()
1405            .and_then(|s| s.last_sample())
1406            .map(|sample| sample.position)
1407    }
1408
1409    /// Get the drag delta (current mouse position minus mouse-down position)
1410    /// from the current input session.
1411    ///
1412    /// Returns `None` if there is no active session or not enough samples.
1413    #[must_use] pub fn get_drag_delta(&self) -> Option<(f32, f32)> {
1414        let session = self.get_current_session()?;
1415        let first = session.first_sample()?;
1416        let last = session.last_sample()?;
1417        Some((
1418            last.position.x - first.position.x,
1419            last.position.y - first.position.y,
1420        ))
1421    }
1422
1423    /// Get the drag delta in **screen-absolute** coordinates.
1424    ///
1425    /// Unlike `get_drag_delta()` which uses window-local coordinates (and therefore
1426    /// oscillates during window drags due to the window moving under the cursor),
1427    /// this method uses screen-absolute positions that are stable regardless of
1428    /// window movement.
1429    ///
1430    /// **Use this for window dragging (titlebar drag).**
1431    /// Use `get_drag_delta()` for in-window operations (node drag-and-drop, etc.).
1432    ///
1433    /// Returns `None` if there is no active session or not enough samples.
1434    #[must_use] pub fn get_drag_delta_screen(&self) -> Option<(f32, f32)> {
1435        let session = self.get_current_session()?;
1436        let first = session.first_sample()?;
1437        let last = session.last_sample()?;
1438        Some((
1439            last.screen_position.x - first.screen_position.x,
1440            last.screen_position.y - first.screen_position.y,
1441        ))
1442    }
1443
1444    /// Get the **incremental** (frame-to-frame) drag delta in screen coordinates.
1445    ///
1446    /// Returns `(dx, dy)` where `dx = last_screen.x - previous_screen.x` and
1447    /// `dy = last_screen.y - previous_screen.y`.
1448    ///
1449    /// Unlike `get_drag_delta_screen()` which returns the *total* delta since drag
1450    /// start, this returns only the delta since the previous sample. This is used
1451    /// by `titlebar_drag` to apply position changes incrementally:
1452    ///
1453    /// ```text
1454    /// new_pos = current_window_pos + incremental_delta
1455    /// ```
1456    ///
1457    /// This approach is more robust than `initial_pos + total_delta` because it
1458    /// automatically handles external window position changes (DPI change, OS
1459    /// clamping, compositor resize) that would make `initial_pos` stale.
1460    ///
1461    /// Returns `None` if there is no active session or fewer than 2 samples.
1462    #[must_use] pub fn get_drag_delta_screen_incremental(&self) -> Option<(f32, f32)> {
1463        let session = self.get_current_session()?;
1464        let len = session.samples.len();
1465        if len < 2 {
1466            return None;
1467        }
1468        let prev = &session.samples[len - 2];
1469        let last = &session.samples[len - 1];
1470        Some((
1471            last.screen_position.x - prev.screen_position.x,
1472            last.screen_position.y - prev.screen_position.y,
1473        ))
1474    }
1475
1476    /// Get the window position that was stored when the current input session
1477    /// started (i.e. on mouse-down).  Titlebar drag callbacks use this
1478    /// together with `get_drag_delta_screen()` to compute the new window position.
1479    #[must_use] pub fn get_window_position_at_session_start(&self) -> Option<WindowPosition> {
1480        let session = self.get_current_session()?;
1481        Some(session.window_position_at_start)
1482    }
1483
1484    // ========================================================================
1485    // UNIFIED DRAG CONTEXT API (NEW)
1486    // ========================================================================
1487
1488    /// Get the active drag context (if any)
1489    #[must_use] pub const fn get_drag_context(&self) -> Option<&DragContext> {
1490        self.active_drag.as_ref()
1491    }
1492
1493    /// Get the active drag context mutably (if any)
1494    pub const fn get_drag_context_mut(&mut self) -> Option<&mut DragContext> {
1495        self.active_drag.as_mut()
1496    }
1497
1498    // NOTE: text-selection and scrollbar-thumb drags do NOT flow through this
1499    // manager's `active_drag`. Text selection is driven by `MultiCursorState`
1500    // (managers/selection.rs) and scrollbar dragging by `ScrollbarDragState`
1501    // (window.rs, set in common/event.rs). The former `activate_text_selection_drag`
1502    // / `activate_scrollbar_drag` constructors here were dead duplicates of those
1503    // paths (zero callers) and were removed.
1504
1505    /// Activate a node drag-and-drop
1506    pub fn activate_node_drag(
1507        &mut self,
1508        dom_id: DomId,
1509        node_id: NodeId,
1510        drag_data: DragData,
1511        _start_hit_test: Option<HitTest>,
1512    ) {
1513        if let Some(detected) = self.detect_drag() {
1514            self.active_drag = Some(DragContext::node_drag(
1515                dom_id,
1516                node_id,
1517                detected.start_position,
1518                drag_data,
1519                detected.session_id,
1520            ));
1521        }
1522    }
1523
1524    /// Activate a window move drag (titlebar)
1525    pub fn activate_window_drag(
1526        &mut self,
1527        initial_window_position: WindowPosition,
1528        _start_hit_test: Option<HitTest>,
1529    ) {
1530        if let Some(detected) = self.detect_drag() {
1531            self.active_drag = Some(DragContext::window_move(
1532                detected.start_position,
1533                initial_window_position,
1534                detected.session_id,
1535            ));
1536        }
1537    }
1538
1539    // NOTE: OS file drops are tracked by `FileDropManager` (managers/file_drop.rs),
1540    // not by this manager's `active_drag`. The former `start_file_drop` constructor
1541    // here was a dead duplicate (zero callers) and was removed.
1542
1543    /// Update positions for active drag (call on mouse move)
1544    pub const fn update_active_drag_positions(&mut self, position: LogicalPosition) {
1545        if let Some(ref mut drag) = self.active_drag {
1546            drag.update_position(position);
1547        }
1548    }
1549
1550    /// Update drop target for node or file drag
1551    pub fn update_drop_target(&mut self, target: Option<azul_core::dom::DomNodeId>) {
1552        if let Some(ref mut drag) = self.active_drag {
1553            match &mut drag.drag_type {
1554                ActiveDragType::Node(ref mut node_drag) => {
1555                    node_drag.current_drop_target = target.into();
1556                }
1557                ActiveDragType::FileDrop(ref mut file_drop) => {
1558                    file_drop.drop_target = target.into();
1559                }
1560                _ => {}
1561            }
1562        }
1563    }
1564
1565    /// Update auto-scroll direction for text selection drag
1566    pub const fn update_auto_scroll_direction(&mut self, direction: AutoScrollDirection) {
1567        if let Some(ref mut drag) = self.active_drag {
1568            if let Some(text_drag) = drag.as_text_selection_mut() {
1569                text_drag.auto_scroll_direction = direction;
1570            }
1571        }
1572    }
1573
1574    /// End the current drag and return the context
1575    pub const fn end_drag(&mut self) -> Option<DragContext> {
1576        self.active_drag.take()
1577    }
1578
1579    /// Cancel the current drag
1580    pub fn cancel_drag(&mut self) {
1581        if let Some(ref mut drag) = self.active_drag {
1582            drag.cancelled = true;
1583        }
1584        self.active_drag = None;
1585    }
1586
1587    // ========================================================================
1588    // QUERY METHODS
1589    // ========================================================================
1590
1591    /// Check if any drag operation is in progress
1592    #[must_use] pub const fn is_dragging(&self) -> bool {
1593        self.active_drag.is_some()
1594    }
1595
1596    /// Check if a text selection drag is active
1597    #[must_use] pub fn is_text_selection_dragging(&self) -> bool {
1598        self.active_drag.as_ref().is_some_and(DragContext::is_text_selection)
1599    }
1600
1601    /// Check if a scrollbar thumb drag is active
1602    #[must_use] pub fn is_scrollbar_dragging(&self) -> bool {
1603        self.active_drag.as_ref().is_some_and(DragContext::is_scrollbar_thumb)
1604    }
1605
1606    /// Check if a node drag is active
1607    #[must_use] pub fn is_node_drag_active(&self) -> bool {
1608        self.active_drag.as_ref().is_some_and(DragContext::is_node_drag)
1609    }
1610
1611    /// Check if a specific node is being dragged
1612    #[must_use] pub fn is_node_dragging(&self, dom_id: DomId, node_id: NodeId) -> bool {
1613        self.active_drag.as_ref().is_some_and(|d| {
1614            d.as_node_drag().is_some_and(|node_drag| node_drag.dom_id == dom_id && node_drag.node_id == node_id)
1615        })
1616    }
1617
1618    /// Check if window drag is active
1619    #[must_use] pub fn is_window_dragging(&self) -> bool {
1620        self.active_drag.as_ref().is_some_and(DragContext::is_window_move)
1621    }
1622
1623    /// Check if file drop is active
1624    #[must_use] pub fn is_file_dropping(&self) -> bool {
1625        self.active_drag.as_ref().is_some_and(DragContext::is_file_drop)
1626    }
1627
1628    /// Get number of active input sessions
1629    #[must_use] pub const fn session_count(&self) -> usize {
1630        self.input_sessions.len()
1631    }
1632
1633    /// Get current session ID (if any)
1634    #[must_use] pub fn current_session_id(&self) -> Option<u64> {
1635        self.get_current_session().map(|s| s.session_id)
1636    }
1637
1638    // ========================================================================
1639    // WINDOW DRAG HELPER METHODS
1640    // ========================================================================
1641
1642    /// Calculate window position delta from current drag state
1643    ///
1644    /// Returns (`delta_x`, `delta_y`) to apply to window position.
1645    /// Returns None if no window drag is active or drag hasn't moved.
1646    #[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
1647    #[must_use] pub fn get_window_drag_delta(&self) -> Option<(i32, i32)> {
1648        let drag = self.active_drag.as_ref()?.as_window_move()?;
1649
1650        let delta_x = drag.current_position.x - drag.start_position.x;
1651        let delta_y = drag.current_position.y - drag.start_position.y;
1652
1653        match drag.initial_window_position {
1654            WindowPosition::Initialized(_initial_pos) => Some((delta_x as i32, delta_y as i32)),
1655            _ => None,
1656        }
1657    }
1658
1659    /// Get the new window position based on current drag
1660    ///
1661    /// Returns the absolute window position to set.
1662    #[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
1663    #[must_use] pub fn get_window_position_from_drag(&self) -> Option<WindowPosition> {
1664        let drag = self.active_drag.as_ref()?.as_window_move()?;
1665
1666        let delta_x = drag.current_position.x - drag.start_position.x;
1667        let delta_y = drag.current_position.y - drag.start_position.y;
1668
1669        match drag.initial_window_position {
1670            WindowPosition::Initialized(initial_pos) => {
1671                Some(WindowPosition::Initialized(PhysicalPositionI32::new(
1672                    initial_pos.x + delta_x as i32,
1673                    initial_pos.y + delta_y as i32,
1674                )))
1675            }
1676            _ => None,
1677        }
1678    }
1679
1680    /// Calculate the new scroll offset for scrollbar thumb drag
1681    #[must_use] pub fn get_scrollbar_scroll_offset(&self) -> Option<f32> {
1682        self.active_drag.as_ref()?.calculate_scrollbar_scroll_offset()
1683    }
1684
1685}
1686
1687impl crate::managers::NodeIdRemap for GestureAndDragManager {
1688    /// Remap `NodeIds` in the active drag context after DOM reconciliation.
1689    ///
1690    /// When the DOM is regenerated during an active drag, `NodeIds` change.
1691    /// If a critical `NodeId` was unmounted, the drag is cancelled (an active
1692    /// drag whose source node no longer exists cannot be completed).
1693    fn remap_node_ids(&mut self, dom_id: DomId, map: &crate::managers::NodeIdMap) {
1694        if let Some(ref mut drag) = self.active_drag {
1695            if !drag.remap_node_ids(dom_id, map.as_btree_map()) {
1696                // Critical node removed — cancel the drag
1697                drag.cancelled = true;
1698                self.active_drag = None;
1699            }
1700        }
1701    }
1702}
1703
1704#[cfg(test)]
1705mod touch_session_tests {
1706    use super::*;
1707    use azul_core::task::{Instant as TestInstant, SystemTick};
1708
1709    fn ts(n: u64) -> CoreInstant {
1710        TestInstant::Tick(SystemTick::new(n))
1711    }
1712
1713    fn pos(x: f32, y: f32) -> LogicalPosition {
1714        LogicalPosition { x, y }
1715    }
1716
1717    #[test]
1718    fn two_fingers_open_two_concurrent_sessions() {
1719        let mut m = GestureAndDragManager::new();
1720        m.touch_down(1, pos(100.0, 100.0), ts(0), WindowPosition::Uninitialized, pos(100.0, 100.0));
1721        m.touch_down(2, pos(200.0, 100.0), ts(1), WindowPosition::Uninitialized, pos(200.0, 100.0));
1722        assert_eq!(m.input_sessions.len(), 2);
1723        assert!(!m.input_sessions[0].ended);
1724        assert!(!m.input_sessions[1].ended);
1725    }
1726
1727    #[test]
1728    fn moves_land_in_the_correct_session_not_the_last_one() {
1729        let mut m = GestureAndDragManager::new();
1730        m.touch_down(1, pos(100.0, 100.0), ts(0), WindowPosition::Uninitialized, pos(100.0, 100.0));
1731        m.touch_down(2, pos(200.0, 100.0), ts(1), WindowPosition::Uninitialized, pos(200.0, 100.0));
1732        // Move finger 1 — the FIRST session must receive the sample even
1733        // though session 2 is the most recent (record_input_sample would
1734        // have corrupted session 2 here).
1735        assert!(m.touch_move(1, pos(90.0, 100.0), ts(2), pos(90.0, 100.0)));
1736        assert_eq!(m.input_sessions[0].samples.len(), 2, "finger 1 session grew");
1737        assert_eq!(m.input_sessions[1].samples.len(), 1, "finger 2 session untouched");
1738    }
1739
1740    #[test]
1741    fn spread_gesture_is_detected_as_pinch_out() {
1742        let mut m = GestureAndDragManager::new();
1743        m.touch_down(1, pos(100.0, 100.0), ts(0), WindowPosition::Uninitialized, pos(100.0, 100.0));
1744        m.touch_down(2, pos(200.0, 100.0), ts(1), WindowPosition::Uninitialized, pos(200.0, 100.0));
1745        // Spread: initial distance 100 → current distance 200.
1746        m.touch_move(1, pos(50.0, 100.0), ts(2), pos(50.0, 100.0));
1747        m.touch_move(2, pos(250.0, 100.0), ts(3), pos(250.0, 100.0));
1748        let pinch = m.detect_pinch().expect("two concurrent touch sessions must yield a pinch");
1749        assert!(
1750            pinch.scale > 1.5,
1751            "spread must read as pinch-out (scale {}), initial {} current {}",
1752            pinch.scale,
1753            pinch.initial_distance,
1754            pinch.current_distance
1755        );
1756    }
1757
1758    #[test]
1759    fn touch_up_ends_only_its_own_session() {
1760        let mut m = GestureAndDragManager::new();
1761        m.touch_down(1, pos(100.0, 100.0), ts(0), WindowPosition::Uninitialized, pos(100.0, 100.0));
1762        m.touch_down(2, pos(200.0, 100.0), ts(1), WindowPosition::Uninitialized, pos(200.0, 100.0));
1763        m.touch_up(1, pos(100.0, 100.0), ts(2), pos(100.0, 100.0));
1764        assert!(m.input_sessions[0].ended);
1765        assert!(!m.input_sessions[1].ended);
1766        // Further moves for the lifted finger are ignored.
1767        assert!(!m.touch_move(1, pos(0.0, 0.0), ts(3), pos(0.0, 0.0)));
1768    }
1769}
1770
1771#[cfg(test)]
1772#[allow(clippy::float_cmp, clippy::unreadable_literal)]
1773mod autotest_generated {
1774    use azul_core::{
1775        drag::ScrollbarAxis, geom::PhysicalPositionI32, styled_dom::NodeHierarchyItemId,
1776        task::{SystemTick, SystemTickDiff, SystemTimeDiff},
1777    };
1778
1779    use super::*;
1780
1781    // ---------------------------------------------------------------- helpers
1782
1783    /// Tick-based instant: 1 tick == 1 ms for `duration_to_millis`.
1784    fn ts(n: u64) -> CoreInstant {
1785        CoreInstant::Tick(SystemTick::new(n))
1786    }
1787
1788    fn pos(x: f32, y: f32) -> LogicalPosition {
1789        LogicalPosition { x, y }
1790    }
1791
1792    /// A sample with window-local == screen position (the common mouse case).
1793    fn sample(x: f32, y: f32, tick: u64) -> InputSample {
1794        InputSample {
1795            position: pos(x, y),
1796            screen_position: pos(x, y),
1797            timestamp: ts(tick),
1798            button_state: 0x01,
1799            event_id: 0,
1800            pressure: 0.5,
1801            tilt: (0.0, 0.0),
1802            touch_radius: (0.0, 0.0),
1803        }
1804    }
1805
1806    /// A synthetic *ended* session — the only way to build a >2-click history,
1807    /// because `start_input_session` prunes all but the newest ended session.
1808    fn ended_session(session_id: u64, samples: Vec<InputSample>) -> InputSession {
1809        InputSession {
1810            samples,
1811            ended: true,
1812            session_id,
1813            window_position_at_start: WindowPosition::Uninitialized,
1814        }
1815    }
1816
1817    /// Press at `from`, move to `to`, `hold_ms` apart — a session that
1818    /// `detect_drag` will accept (distance permitting).
1819    fn dragging_manager(
1820        from: LogicalPosition,
1821        to: LogicalPosition,
1822        hold_ms: u64,
1823    ) -> GestureAndDragManager {
1824        let mut m = GestureAndDragManager::new();
1825        m.start_input_session(from, ts(0), 0x01, WindowPosition::Uninitialized, from);
1826        let recorded = m.record_input_sample(to, ts(hold_ms), 0x01, to);
1827        assert!(recorded);
1828        m
1829    }
1830
1831    // ------------------------------------------------- duration_to_millis (private)
1832
1833    #[test]
1834    fn duration_to_millis_tick_zero_and_max_do_not_panic() {
1835        assert_eq!(
1836            duration_to_millis(CoreDuration::Tick(SystemTickDiff { tick_diff: 0 })),
1837            0
1838        );
1839        assert_eq!(
1840            duration_to_millis(CoreDuration::Tick(SystemTickDiff {
1841                tick_diff: u64::MAX
1842            })),
1843            u64::MAX
1844        );
1845    }
1846
1847    #[cfg(feature = "std")]
1848    #[test]
1849    fn duration_to_millis_system_zero_and_sub_millisecond_floor() {
1850        assert_eq!(
1851            duration_to_millis(CoreDuration::System(SystemTimeDiff { secs: 0, nanos: 0 })),
1852            0
1853        );
1854        // 999_999 ns is under a millisecond => floors to 0, never rounds up.
1855        assert_eq!(
1856            duration_to_millis(CoreDuration::System(SystemTimeDiff {
1857                secs: 0,
1858                nanos: 999_999
1859            })),
1860            0
1861        );
1862        assert_eq!(
1863            duration_to_millis(CoreDuration::System(SystemTimeDiff {
1864                secs: 2,
1865                nanos: 500_000_000
1866            })),
1867            2500
1868        );
1869    }
1870
1871    #[cfg(feature = "std")]
1872    #[test]
1873    fn duration_to_millis_system_max_truncates_instead_of_panicking() {
1874        // as_millis() is u128 and would be MAX*1000+999; the `as u64` cast
1875        // truncates rather than panicking or saturating. Lock the exact value
1876        // so a change to saturating semantics is caught.
1877        let d = CoreDuration::System(SystemTimeDiff {
1878            secs: u64::MAX,
1879            nanos: 999_999_999,
1880        });
1881        let expected = ((u64::MAX as u128) * 1000 + 999) as u64;
1882        assert_eq!(duration_to_millis(d), expected);
1883    }
1884
1885    // ------------------------------------------------- WacomPadState::express_key
1886
1887    #[test]
1888    fn express_key_out_of_range_index_is_false_not_a_shift_overflow() {
1889        // 1u32 << 32 would panic in debug; the `index < 32` guard must short-circuit.
1890        let pad = WacomPadState {
1891            express_keys: u32::MAX,
1892            touch_ring: 0.0,
1893            touch_ring_active: false,
1894            device_id: 0,
1895        };
1896        assert!(pad.express_key(31));
1897        assert!(!pad.express_key(32));
1898        assert!(!pad.express_key(33));
1899        assert!(!pad.express_key(u32::MAX));
1900    }
1901
1902    #[test]
1903    fn express_key_default_pad_has_no_keys_held() {
1904        let pad = WacomPadState::default();
1905        for i in 0..40u32 {
1906            assert!(!pad.express_key(i), "bit {i} must be unset on a default pad");
1907        }
1908    }
1909
1910    #[test]
1911    fn express_key_bitset_round_trips_every_bit() {
1912        for bit in 0..32u32 {
1913            let pad = WacomPadState {
1914                express_keys: 1u32 << bit,
1915                touch_ring: 0.0,
1916                touch_ring_active: false,
1917                device_id: 0,
1918            };
1919            for probe in 0..32u32 {
1920                assert_eq!(
1921                    pad.express_key(probe),
1922                    probe == bit,
1923                    "encode bit {bit} -> decode probe {probe}"
1924                );
1925            }
1926        }
1927    }
1928
1929    // ------------------------------------------------- InputSession
1930
1931    #[test]
1932    fn input_session_new_holds_its_construction_invariants() {
1933        let s = InputSession::new(
1934            u64::MAX,
1935            sample(1.0, 2.0, 7),
1936            WindowPosition::Initialized(PhysicalPositionI32::new(-5, 9)),
1937        );
1938        assert_eq!(s.session_id, u64::MAX);
1939        assert!(!s.ended);
1940        assert_eq!(s.samples.len(), 1);
1941        assert_eq!(s.first_sample(), s.last_sample());
1942        assert_eq!(
1943            s.window_position_at_start,
1944            WindowPosition::Initialized(PhysicalPositionI32::new(-5, 9))
1945        );
1946        assert_eq!(s.total_distance(), 0.0);
1947        assert_eq!(s.direct_distance(), Some(0.0));
1948        assert_eq!(s.duration_ms(), Some(0));
1949    }
1950
1951    #[test]
1952    fn empty_session_getters_return_none_instead_of_panicking() {
1953        let s = InputSession {
1954            samples: Vec::new(),
1955            ended: false,
1956            session_id: 0,
1957            window_position_at_start: WindowPosition::Uninitialized,
1958        };
1959        assert!(s.first_sample().is_none());
1960        assert!(s.last_sample().is_none());
1961        assert!(s.duration_ms().is_none());
1962        assert!(s.direct_distance().is_none());
1963        assert_eq!(s.total_distance(), 0.0);
1964    }
1965
1966    #[test]
1967    fn duration_ms_saturates_to_zero_when_time_runs_backwards() {
1968        // last sample is *earlier* than the first (reordered / skewed clock).
1969        let s = InputSession {
1970            samples: vec![sample(0.0, 0.0, 900), sample(0.0, 0.0, 100)],
1971            ended: false,
1972            session_id: 1,
1973            window_position_at_start: WindowPosition::Uninitialized,
1974        };
1975        assert_eq!(s.duration_ms(), Some(0));
1976    }
1977
1978    #[cfg(feature = "std")]
1979    #[test]
1980    fn duration_ms_with_mismatched_instant_kinds_is_zero() {
1981        let mut first = sample(0.0, 0.0, 0);
1982        first.timestamp = CoreInstant::now(); // System variant
1983        let last = sample(0.0, 0.0, 5_000); // Tick variant
1984        let s = InputSession {
1985            samples: vec![first, last],
1986            ended: false,
1987            session_id: 1,
1988            window_position_at_start: WindowPosition::Uninitialized,
1989        };
1990        assert_eq!(s.duration_ms(), Some(0));
1991    }
1992
1993    #[test]
1994    fn total_distance_sums_the_path_while_direct_distance_is_the_chord() {
1995        let s = InputSession {
1996            samples: vec![
1997                sample(0.0, 0.0, 0),
1998                sample(3.0, 0.0, 1),
1999                sample(3.0, 4.0, 2),
2000            ],
2001            ended: false,
2002            session_id: 1,
2003            window_position_at_start: WindowPosition::Uninitialized,
2004        };
2005        assert_eq!(s.total_distance(), 7.0);
2006        assert_eq!(s.direct_distance(), Some(5.0));
2007    }
2008
2009    #[test]
2010    fn distances_with_nan_coordinates_are_nan_and_do_not_panic() {
2011        let s = InputSession {
2012            samples: vec![sample(0.0, 0.0, 0), sample(f32::NAN, f32::NAN, 1)],
2013            ended: false,
2014            session_id: 1,
2015            window_position_at_start: WindowPosition::Uninitialized,
2016        };
2017        assert!(s.total_distance().is_nan());
2018        assert!(s.direct_distance().is_some_and(f32::is_nan));
2019    }
2020
2021    #[test]
2022    fn distances_at_f32_extremes_saturate_to_infinity_instead_of_panicking() {
2023        let s = InputSession {
2024            samples: vec![
2025                sample(-f32::MAX, -f32::MAX, 0),
2026                sample(f32::MAX, f32::MAX, 1),
2027            ],
2028            ended: false,
2029            session_id: 1,
2030            window_position_at_start: WindowPosition::Uninitialized,
2031        };
2032        assert!(s.total_distance().is_infinite());
2033        assert!(s.direct_distance().is_some_and(f32::is_infinite));
2034    }
2035
2036    // ------------------------------------------------- construction
2037
2038    #[test]
2039    fn new_manager_is_inert_and_every_detector_is_quiet() {
2040        let m = GestureAndDragManager::new();
2041        assert_eq!(m.session_count(), 0);
2042        assert_eq!(m.debug_counts(), (0, 0));
2043        assert!(m.current_session_id().is_none());
2044        assert!(m.get_current_session().is_none());
2045        assert!(m.get_current_mouse_position().is_none());
2046        assert!(m.get_pen_state().is_none());
2047        assert!(m.get_previous_pen_state().is_none());
2048        assert!(m.get_pad_state().is_none());
2049        assert!(m.get_drag_context().is_none());
2050        assert!(m.detect_drag().is_none());
2051        assert!(m.detect_long_press().is_none());
2052        assert!(!m.detect_double_click());
2053        assert!(m.get_drag_direction().is_none());
2054        assert!(m.get_gesture_velocity().is_none());
2055        assert!(!m.is_swipe());
2056        assert!(m.detect_swipe_direction().is_none());
2057        assert!(m.detect_pinch().is_none());
2058        assert!(m.detect_rotation().is_none());
2059        assert!(m.get_drag_delta().is_none());
2060        assert!(m.get_drag_delta_screen().is_none());
2061        assert!(m.get_drag_delta_screen_incremental().is_none());
2062        assert!(m.get_window_position_at_session_start().is_none());
2063        assert!(m.get_window_drag_delta().is_none());
2064        assert!(m.get_window_position_from_drag().is_none());
2065        assert!(m.get_scrollbar_scroll_offset().is_none());
2066        assert!(!m.is_dragging());
2067        assert!(!m.is_text_selection_dragging());
2068        assert!(!m.is_scrollbar_dragging());
2069        assert!(!m.is_node_drag_active());
2070        assert!(!m.is_window_dragging());
2071        assert!(!m.is_file_dropping());
2072        assert!(!m.is_node_dragging(DomId::ROOT_ID, NodeId::ZERO));
2073        // Documented default for "no history at all".
2074        assert_eq!(m.detect_click_count(), 1);
2075        assert_eq!(m, GestureAndDragManager::default());
2076    }
2077
2078    #[test]
2079    fn with_config_keeps_extreme_thresholds_verbatim_and_still_starts_at_session_1() {
2080        let cfg = GestureDetectionConfig {
2081            drag_distance_threshold: f32::NAN,
2082            double_click_time_threshold_ms: u64::MAX,
2083            double_click_distance_threshold: f32::INFINITY,
2084            long_press_time_threshold_ms: 0,
2085            long_press_distance_threshold: -1.0,
2086            min_samples_for_gesture: usize::MAX,
2087            swipe_velocity_threshold: 0.0,
2088            pinch_scale_threshold: f32::MAX,
2089            rotation_angle_threshold: -0.0,
2090            sample_cleanup_interval_ms: 0,
2091        };
2092        let mut m = GestureAndDragManager::with_config(cfg);
2093        assert!(m.config.drag_distance_threshold.is_nan());
2094        assert_eq!(m.config.double_click_time_threshold_ms, u64::MAX);
2095        assert_eq!(m.config.min_samples_for_gesture, usize::MAX);
2096        assert_eq!(m.session_count(), 0);
2097        let id = m.start_input_session(
2098            pos(0.0, 0.0),
2099            ts(0),
2100            0x01,
2101            WindowPosition::Uninitialized,
2102            pos(0.0, 0.0),
2103        );
2104        assert_eq!(id, 1, "with_config must not disturb the session counter");
2105        // min_samples_for_gesture == usize::MAX can never be reached => no drag.
2106        assert!(m.detect_drag().is_none());
2107    }
2108
2109    // ------------------------------------------------- session recording
2110
2111    #[test]
2112    fn session_ids_are_monotonic_starting_at_one() {
2113        let mut m = GestureAndDragManager::new();
2114        for expected in 1..=5u64 {
2115            let id = m.start_input_session(
2116                pos(0.0, 0.0),
2117                ts(expected),
2118                0x01,
2119                WindowPosition::Uninitialized,
2120                pos(0.0, 0.0),
2121            );
2122            assert_eq!(id, expected);
2123            assert_eq!(m.current_session_id(), Some(expected));
2124            m.end_current_session();
2125        }
2126    }
2127
2128    #[test]
2129    fn session_id_counter_at_the_u64_boundary_does_not_overflow() {
2130        let mut m = GestureAndDragManager::new();
2131        m.next_session_id = u64::MAX - 1;
2132        let id = m.start_input_session(
2133            pos(0.0, 0.0),
2134            ts(0),
2135            0xFF,
2136            WindowPosition::Uninitialized,
2137            pos(0.0, 0.0),
2138        );
2139        assert_eq!(id, u64::MAX - 1);
2140        assert_eq!(m.next_session_id, u64::MAX);
2141    }
2142
2143    #[test]
2144    fn recording_without_or_after_a_session_returns_false() {
2145        let mut m = GestureAndDragManager::new();
2146        assert!(!m.record_input_sample(pos(1.0, 1.0), ts(1), 0x01, pos(1.0, 1.0)));
2147        m.start_input_session(
2148            pos(0.0, 0.0),
2149            ts(0),
2150            0x01,
2151            WindowPosition::Uninitialized,
2152            pos(0.0, 0.0),
2153        );
2154        assert!(m.record_input_sample(pos(1.0, 1.0), ts(1), 0x01, pos(1.0, 1.0)));
2155        m.end_current_session();
2156        assert!(!m.record_input_sample(pos(2.0, 2.0), ts(2), 0x01, pos(2.0, 2.0)));
2157        // Ending twice is idempotent, and ending nothing must not panic.
2158        m.end_current_session();
2159        m.clear_all_sessions();
2160        m.end_current_session();
2161        assert_eq!(m.session_count(), 0);
2162    }
2163
2164    #[test]
2165    fn sample_count_stays_bounded_by_max_samples_per_session() {
2166        let mut m = GestureAndDragManager::new();
2167        m.start_input_session(
2168            pos(0.0, 0.0),
2169            ts(0),
2170            0x01,
2171            WindowPosition::Uninitialized,
2172            pos(0.0, 0.0),
2173        );
2174        for i in 1..=(MAX_SAMPLES_PER_SESSION as u64 + 200) {
2175            assert!(m.record_input_sample(pos(i as f32, 0.0), ts(i), 0x01, pos(i as f32, 0.0)));
2176            assert!(
2177                m.get_current_session().unwrap().samples.len() <= MAX_SAMPLES_PER_SESSION,
2178                "sample buffer grew past MAX_SAMPLES_PER_SESSION at i={i}"
2179            );
2180        }
2181        // The newest sample always survives the drain.
2182        let last = m.get_current_mouse_position().unwrap();
2183        assert_eq!(last.x, (MAX_SAMPLES_PER_SESSION + 200) as f32);
2184    }
2185
2186    #[test]
2187    fn pen_samples_accept_nan_inf_and_extreme_values() {
2188        let mut m = GestureAndDragManager::new();
2189        let id = m.start_input_session_with_pen(
2190            pos(f32::NAN, f32::INFINITY),
2191            ts(0),
2192            0xFF,
2193            u64::MAX,
2194            f32::NAN,
2195            (f32::INFINITY, f32::NEG_INFINITY),
2196            (-f32::MAX, f32::MAX),
2197            WindowPosition::Uninitialized,
2198            pos(f32::NEG_INFINITY, f32::NAN),
2199        );
2200        assert_eq!(id, 1);
2201        assert!(m.record_input_sample_with_pen(
2202            pos(0.0, 0.0),
2203            ts(u64::MAX),
2204            0x00,
2205            0,
2206            -1.0e30,
2207            (f32::NAN, f32::NAN),
2208            (f32::NAN, f32::NAN),
2209            pos(0.0, 0.0),
2210        ));
2211        let session = m.get_current_session().unwrap();
2212        assert_eq!(session.samples.len(), 2);
2213        let first = session.first_sample().unwrap();
2214        assert!(first.pressure.is_nan());
2215        assert!(first.tilt.0.is_infinite());
2216        assert_eq!(first.button_state, 0xFF);
2217        assert_eq!(first.event_id, u64::MAX);
2218        // ts(u64::MAX) - ts(0) fits: duration_since is a saturating u64 sub.
2219        assert_eq!(session.duration_ms(), Some(u64::MAX));
2220        // NaN/inf coordinates must not make any detector panic. `hypot(NaN, inf)`
2221        // is `+inf` per IEEE-754, so this DOES read as a drag — but only with a
2222        // non-finite distance, never a plausible-looking finite one.
2223        assert!(m.detect_drag().is_none_or(|d| !d.direct_distance.is_finite()));
2224        assert!(m.get_drag_direction().is_some());
2225    }
2226
2227    #[test]
2228    fn starting_a_session_prunes_all_but_the_newest_ended_session() {
2229        let mut m = GestureAndDragManager::new();
2230        for tick in [0u64, 10, 20] {
2231            m.start_input_session(
2232                pos(0.0, 0.0),
2233                ts(tick),
2234                0x01,
2235                WindowPosition::Uninitialized,
2236                pos(0.0, 0.0),
2237            );
2238            m.end_current_session();
2239        }
2240        // Bounded growth: never more than "one ended + one live" session.
2241        assert_eq!(m.session_count(), 2);
2242        assert_eq!(m.input_sessions[0].session_id, 2);
2243        assert_eq!(m.input_sessions[1].session_id, 3);
2244        // KNOWN LIMITATION: because the history is pruned to a single ended
2245        // session, a genuine triple-click through the public API can only ever
2246        // report 2. detect_click_count()'s triple-click arm is unreachable here.
2247        assert_eq!(m.detect_click_count(), 2);
2248    }
2249
2250    // ------------------------------------------------- touch sessions
2251
2252    #[test]
2253    fn touch_ids_at_zero_and_u64_max_are_tracked_independently() {
2254        let mut m = GestureAndDragManager::new();
2255        m.touch_down(
2256            0,
2257            pos(0.0, 0.0),
2258            ts(0),
2259            WindowPosition::Uninitialized,
2260            pos(0.0, 0.0),
2261        );
2262        m.touch_down(
2263            u64::MAX,
2264            pos(50.0, 0.0),
2265            ts(1),
2266            WindowPosition::Uninitialized,
2267            pos(50.0, 0.0),
2268        );
2269        assert_eq!(m.session_count(), 2);
2270        assert!(m.touch_move(0, pos(1.0, 1.0), ts(2), pos(1.0, 1.0)));
2271        assert!(m.touch_move(u64::MAX, pos(60.0, 0.0), ts(3), pos(60.0, 0.0)));
2272        assert_eq!(m.input_sessions[0].samples.len(), 2);
2273        assert_eq!(m.input_sessions[1].samples.len(), 2);
2274        m.touch_up(0, pos(1.0, 1.0), ts(4), pos(1.0, 1.0));
2275        assert!(m.input_sessions[0].ended);
2276        assert!(!m.input_sessions[1].ended);
2277    }
2278
2279    #[test]
2280    fn touch_events_for_unknown_ids_are_ignored_without_panicking() {
2281        let mut m = GestureAndDragManager::new();
2282        assert!(!m.touch_move(42, pos(0.0, 0.0), ts(0), pos(0.0, 0.0)));
2283        m.touch_up(42, pos(0.0, 0.0), ts(1), pos(0.0, 0.0));
2284        m.touch_cancel_all(); // nothing to cancel
2285        assert_eq!(m.session_count(), 0);
2286    }
2287
2288    #[test]
2289    fn a_repeated_touch_down_for_the_same_id_rebinds_to_the_newest_session() {
2290        let mut m = GestureAndDragManager::new();
2291        m.touch_down(
2292            7,
2293            pos(0.0, 0.0),
2294            ts(0),
2295            WindowPosition::Uninitialized,
2296            pos(0.0, 0.0),
2297        );
2298        m.touch_down(
2299            7,
2300            pos(9.0, 9.0),
2301            ts(1),
2302            WindowPosition::Uninitialized,
2303            pos(9.0, 9.0),
2304        );
2305        assert_eq!(m.touch_sessions.len(), 1, "the id map must not grow");
2306        assert_eq!(m.session_count(), 2);
2307        assert_eq!(m.touch_sessions.get(&7).copied(), Some(2));
2308        // touch_up ends only the session the id currently maps to; the orphaned
2309        // first session stays open until clear_old_sessions() reaps it.
2310        m.touch_up(7, pos(9.0, 9.0), ts(2), pos(9.0, 9.0));
2311        assert!(!m.input_sessions[0].ended);
2312        assert!(m.input_sessions[1].ended);
2313        assert!(m.touch_sessions.is_empty());
2314    }
2315
2316    #[test]
2317    fn touch_cancel_all_ends_every_finger_and_empties_the_id_map() {
2318        let mut m = GestureAndDragManager::new();
2319        for id in 0..3u64 {
2320            m.touch_down(
2321                id,
2322                pos(id as f32 * 10.0, 0.0),
2323                ts(id),
2324                WindowPosition::Uninitialized,
2325                pos(id as f32 * 10.0, 0.0),
2326            );
2327        }
2328        m.touch_cancel_all();
2329        assert!(m.touch_sessions.is_empty());
2330        assert!(m.input_sessions.iter().all(|s| s.ended));
2331        assert!(!m.touch_move(1, pos(0.0, 0.0), ts(9), pos(0.0, 0.0)));
2332    }
2333
2334    #[test]
2335    fn touch_moves_after_clear_all_sessions_are_dropped_not_resurrected() {
2336        let mut m = GestureAndDragManager::new();
2337        m.touch_down(
2338            1,
2339            pos(0.0, 0.0),
2340            ts(0),
2341            WindowPosition::Uninitialized,
2342            pos(0.0, 0.0),
2343        );
2344        m.clear_all_sessions();
2345        // The id->session map still holds a dangling entry, but the by-id
2346        // lookup finds no session, so nothing is recorded and nothing panics.
2347        assert!(!m.touch_move(1, pos(5.0, 5.0), ts(1), pos(5.0, 5.0)));
2348        assert_eq!(m.session_count(), 0);
2349    }
2350
2351    #[test]
2352    fn record_sample_for_session_rejects_unknown_and_ended_sessions() {
2353        let mut m = GestureAndDragManager::new();
2354        assert!(!m.record_sample_for_session(u64::MAX, pos(0.0, 0.0), ts(0), pos(0.0, 0.0)));
2355        let id = m.start_input_session(
2356            pos(0.0, 0.0),
2357            ts(0),
2358            0x01,
2359            WindowPosition::Uninitialized,
2360            pos(0.0, 0.0),
2361        );
2362        assert!(m.record_sample_for_session(id, pos(1.0, 0.0), ts(1), pos(1.0, 0.0)));
2363        assert!(!m.record_sample_for_session(0, pos(1.0, 0.0), ts(1), pos(1.0, 0.0)));
2364        m.end_current_session();
2365        assert!(!m.record_sample_for_session(id, pos(2.0, 0.0), ts(2), pos(2.0, 0.0)));
2366        assert_eq!(m.input_sessions[0].samples.len(), 2);
2367    }
2368
2369    #[test]
2370    fn record_sample_for_session_is_also_bounded_by_max_samples() {
2371        let mut m = GestureAndDragManager::new();
2372        m.touch_down(
2373            1,
2374            pos(0.0, 0.0),
2375            ts(0),
2376            WindowPosition::Uninitialized,
2377            pos(0.0, 0.0),
2378        );
2379        for i in 1..=(MAX_SAMPLES_PER_SESSION as u64 + 150) {
2380            assert!(m.touch_move(1, pos(i as f32, 0.0), ts(i), pos(i as f32, 0.0)));
2381        }
2382        assert!(m.input_sessions[0].samples.len() <= MAX_SAMPLES_PER_SESSION);
2383    }
2384
2385    // ------------------------------------------------- cleanup
2386
2387    #[test]
2388    fn clear_old_sessions_reaps_stale_sessions_and_their_long_press_ids() {
2389        let mut m = GestureAndDragManager::new();
2390        let old = m.start_input_session(
2391            pos(0.0, 0.0),
2392            ts(0),
2393            0x01,
2394            WindowPosition::Uninitialized,
2395            pos(0.0, 0.0),
2396        );
2397        m.end_current_session();
2398        m.mark_long_press_callback_invoked(old);
2399        let fresh = m.start_input_session(
2400            pos(0.0, 0.0),
2401            ts(10_000),
2402            0x01,
2403            WindowPosition::Uninitialized,
2404            pos(0.0, 0.0),
2405        );
2406        m.mark_long_press_callback_invoked(fresh);
2407        assert_eq!(m.debug_counts(), (2, 2));
2408
2409        // Now is 10_050 ticks: `old` is 10s stale (> 2000ms), `fresh` is 50ms old.
2410        m.clear_old_sessions(ts(10_050));
2411        assert_eq!(m.session_count(), 1);
2412        assert_eq!(m.current_session_id(), Some(fresh));
2413        assert_eq!(
2414            m.debug_counts(),
2415            (1, 1),
2416            "long-press bookkeeping must not grow unboundedly"
2417        );
2418    }
2419
2420    #[test]
2421    fn clear_old_sessions_drops_sessions_that_have_no_samples() {
2422        let mut m = GestureAndDragManager::new();
2423        m.input_sessions.push(InputSession {
2424            samples: Vec::new(),
2425            ended: false,
2426            session_id: 99,
2427            window_position_at_start: WindowPosition::Uninitialized,
2428        });
2429        m.clear_old_sessions(ts(0));
2430        assert_eq!(m.session_count(), 0);
2431    }
2432
2433    #[test]
2434    fn clear_old_sessions_with_a_backwards_clock_keeps_everything() {
2435        let mut m = GestureAndDragManager::new();
2436        m.start_input_session(
2437            pos(0.0, 0.0),
2438            ts(5_000),
2439            0x01,
2440            WindowPosition::Uninitialized,
2441            pos(0.0, 0.0),
2442        );
2443        // `now` is *before* the sample: duration_since saturates to 0 => age 0.
2444        m.clear_old_sessions(ts(0));
2445        assert_eq!(m.session_count(), 1);
2446    }
2447
2448    #[test]
2449    fn clear_all_sessions_resets_both_counters() {
2450        let mut m = GestureAndDragManager::new();
2451        m.start_input_session(
2452            pos(0.0, 0.0),
2453            ts(0),
2454            0x01,
2455            WindowPosition::Uninitialized,
2456            pos(0.0, 0.0),
2457        );
2458        m.mark_current_long_press_invoked();
2459        assert_eq!(m.debug_counts(), (1, 1));
2460        m.clear_all_sessions();
2461        assert_eq!(m.debug_counts(), (0, 0));
2462        assert!(m.get_current_session().is_none());
2463    }
2464
2465    #[test]
2466    fn long_press_invocation_marks_are_deduplicated() {
2467        let mut m = GestureAndDragManager::new();
2468        for _ in 0..100 {
2469            m.mark_long_press_callback_invoked(u64::MAX);
2470            m.mark_long_press_callback_invoked(0);
2471        }
2472        assert_eq!(m.debug_counts(), (0, 2));
2473        // Marking without a session is a no-op, not a panic.
2474        m.mark_current_long_press_invoked();
2475        assert_eq!(m.debug_counts(), (0, 2));
2476    }
2477
2478    // ------------------------------------------------- drag / long-press detection
2479
2480    #[test]
2481    fn detect_drag_fires_exactly_at_the_distance_threshold() {
2482        // hypot(3, 4) == 5.0 == drag_distance_threshold => `>=` must fire.
2483        let m = dragging_manager(pos(0.0, 0.0), pos(3.0, 4.0), 20);
2484        let drag = m.detect_drag().expect("distance == threshold must be a drag");
2485        assert_eq!(drag.direct_distance, 5.0);
2486        assert_eq!(drag.total_distance, 5.0);
2487        assert_eq!(drag.sample_count, 2);
2488        assert_eq!(drag.duration_ms, 20);
2489        assert_eq!(drag.session_id, 1);
2490        assert_eq!(drag.start_position, pos(0.0, 0.0));
2491        assert_eq!(drag.current_position, pos(3.0, 4.0));
2492
2493        // Just below the threshold: no drag.
2494        let m = dragging_manager(pos(0.0, 0.0), pos(4.9, 0.0), 20);
2495        assert!(m.detect_drag().is_none());
2496    }
2497
2498    #[test]
2499    fn detect_drag_with_nan_movement_returns_none() {
2500        let m = dragging_manager(pos(0.0, 0.0), pos(f32::NAN, f32::NAN), 20);
2501        assert!(
2502            m.detect_drag().is_none(),
2503            "NaN distance is never >= threshold"
2504        );
2505    }
2506
2507    #[test]
2508    fn detect_drag_needs_min_samples_for_gesture() {
2509        let mut m = GestureAndDragManager::new();
2510        m.start_input_session(
2511            pos(0.0, 0.0),
2512            ts(0),
2513            0x01,
2514            WindowPosition::Uninitialized,
2515            pos(500.0, 500.0),
2516        );
2517        assert!(m.detect_drag().is_none(), "one sample is not a gesture");
2518    }
2519
2520    #[test]
2521    fn detect_long_press_honours_time_and_distance_thresholds() {
2522        // Held 500ms (== threshold) without moving => long press.
2523        let m = dragging_manager(pos(10.0, 10.0), pos(10.0, 10.0), 500);
2524        let lp = m.detect_long_press().expect("500ms hold is a long press");
2525        assert_eq!(lp.duration_ms, 500);
2526        assert_eq!(lp.position, pos(10.0, 10.0));
2527        assert!(!lp.callback_invoked);
2528        assert_eq!(lp.session_id, 1);
2529
2530        // One ms short => not yet.
2531        let m = dragging_manager(pos(10.0, 10.0), pos(10.0, 10.0), 499);
2532        assert!(m.detect_long_press().is_none());
2533
2534        // Long enough but moved too far (> 10px).
2535        let m = dragging_manager(pos(0.0, 0.0), pos(11.0, 0.0), 800);
2536        assert!(m.detect_long_press().is_none());
2537    }
2538
2539    #[test]
2540    fn detect_long_press_stops_at_button_up_and_after_being_marked() {
2541        let mut m = dragging_manager(pos(10.0, 10.0), pos(10.0, 10.0), 600);
2542        assert!(m.detect_long_press().is_some());
2543
2544        m.mark_current_long_press_invoked();
2545        let lp = m.detect_long_press().expect("still held");
2546        assert!(
2547            lp.callback_invoked,
2548            "a marked long press must report callback_invoked"
2549        );
2550
2551        m.end_current_session();
2552        assert!(
2553            m.detect_long_press().is_none(),
2554            "a released button cannot be a long press"
2555        );
2556    }
2557
2558    // ------------------------------------------------- click counting
2559
2560    #[test]
2561    fn detect_double_click_checks_both_timing_and_distance() {
2562        let mut m = GestureAndDragManager::new();
2563        m.input_sessions = vec![
2564            ended_session(1, vec![sample(10.0, 10.0, 0)]),
2565            ended_session(2, vec![sample(11.0, 11.0, 100)]),
2566        ];
2567        assert!(m.detect_double_click());
2568
2569        // Too slow (501ms > 500ms).
2570        m.input_sessions[1].samples[0].timestamp = ts(501);
2571        assert!(!m.detect_double_click());
2572
2573        // Fast, but too far apart (>= 5px).
2574        m.input_sessions[1].samples[0].timestamp = ts(100);
2575        m.input_sessions[1].samples[0].position = pos(100.0, 10.0);
2576        assert!(!m.detect_double_click());
2577
2578        // Fast and close, but the second click is still held down.
2579        m.input_sessions[1].samples[0].position = pos(11.0, 11.0);
2580        m.input_sessions[1].ended = false;
2581        assert!(!m.detect_double_click());
2582    }
2583
2584    #[test]
2585    fn detect_double_click_needs_two_sessions() {
2586        let mut m = GestureAndDragManager::new();
2587        m.input_sessions = vec![ended_session(1, vec![sample(0.0, 0.0, 0)])];
2588        assert!(!m.detect_double_click());
2589    }
2590
2591    #[test]
2592    fn detect_click_count_counts_up_to_three_and_stops_at_the_first_gap() {
2593        let mut m = GestureAndDragManager::new();
2594        // Three ended clicks, each 100ms apart at (nearly) the same point.
2595        m.input_sessions = vec![
2596            ended_session(1, vec![sample(10.0, 10.0, 0)]),
2597            ended_session(2, vec![sample(10.0, 11.0, 100)]),
2598            ended_session(3, vec![sample(11.0, 10.0, 200)]),
2599        ];
2600        assert_eq!(m.detect_click_count(), 3);
2601
2602        // Break the middle gap in *time*: only the newest pair counts.
2603        m.input_sessions[2].samples[0].timestamp = ts(900);
2604        assert_eq!(m.detect_click_count(), 1);
2605
2606        // A backwards clock does NOT break the chain: duration_since saturates
2607        // to 0, which reads as "no gap at all" => the click still counts.
2608        m.input_sessions[2].samples[0].timestamp = ts(200);
2609        m.input_sessions[0].samples[0].timestamp = ts(u64::MAX);
2610        assert_eq!(m.detect_click_count(), 3);
2611
2612        // Break the oldest gap in *distance*.
2613        m.input_sessions[0].samples[0].timestamp = ts(0);
2614        m.input_sessions[0].samples[0].position = pos(500.0, 500.0);
2615        assert_eq!(m.detect_click_count(), 2);
2616    }
2617
2618    #[test]
2619    fn detect_click_count_ignores_live_sessions_and_defaults_to_one() {
2620        let mut m = GestureAndDragManager::new();
2621        // Only a live (un-ended) session => nothing to count => 1.
2622        m.start_input_session(
2623            pos(0.0, 0.0),
2624            ts(0),
2625            0x01,
2626            WindowPosition::Uninitialized,
2627            pos(0.0, 0.0),
2628        );
2629        assert_eq!(m.detect_click_count(), 1);
2630        assert_eq!(GestureAndDragManager::new().detect_click_count(), 1);
2631    }
2632
2633    #[test]
2634    fn detect_click_count_with_empty_sample_vec_does_not_panic() {
2635        let mut m = GestureAndDragManager::new();
2636        m.input_sessions = vec![
2637            ended_session(1, Vec::new()),
2638            ended_session(2, vec![sample(0.0, 0.0, 10)]),
2639        ];
2640        assert_eq!(m.detect_click_count(), 1);
2641        assert!(!m.detect_double_click());
2642    }
2643
2644    // ------------------------------------------------- direction / velocity / swipe
2645
2646    #[test]
2647    fn drag_direction_is_deterministic_for_stationary_and_nan_input() {
2648        // No movement at all: dx == dy == 0 => documented fallback is Up.
2649        let m = dragging_manager(pos(5.0, 5.0), pos(5.0, 5.0), 10);
2650        assert_eq!(m.get_drag_direction(), Some(GestureDirection::Up));
2651
2652        // NaN deltas compare false everywhere => same deterministic fallback.
2653        let m = dragging_manager(pos(0.0, 0.0), pos(f32::NAN, f32::NAN), 10);
2654        assert_eq!(m.get_drag_direction(), Some(GestureDirection::Up));
2655    }
2656
2657    #[test]
2658    fn drag_direction_picks_the_dominant_axis() {
2659        let cases = [
2660            (pos(100.0, 1.0), GestureDirection::Right),
2661            (pos(-100.0, 1.0), GestureDirection::Left),
2662            (pos(1.0, 100.0), GestureDirection::Down),
2663            (pos(1.0, -100.0), GestureDirection::Up),
2664            // Perfect diagonal: |dx| > |dy| is false => vertical wins.
2665            (pos(50.0, 50.0), GestureDirection::Down),
2666        ];
2667        for (to, expected) in cases {
2668            let m = dragging_manager(pos(0.0, 0.0), to, 10);
2669            assert_eq!(
2670                m.get_drag_direction(),
2671                Some(expected),
2672                "drag to ({}, {})",
2673                to.x,
2674                to.y
2675            );
2676        }
2677    }
2678
2679    #[test]
2680    fn gesture_velocity_returns_none_instead_of_dividing_by_zero() {
2681        // Two samples with the SAME timestamp => duration 0 => no velocity.
2682        let m = dragging_manager(pos(0.0, 0.0), pos(100.0, 0.0), 0);
2683        assert!(m.get_gesture_velocity().is_none());
2684        assert!(!m.is_swipe());
2685        assert!(m.detect_swipe_direction().is_none());
2686
2687        // A single sample is not enough either.
2688        let mut m = GestureAndDragManager::new();
2689        m.start_input_session(
2690            pos(0.0, 0.0),
2691            ts(0),
2692            0x01,
2693            WindowPosition::Uninitialized,
2694            pos(0.0, 0.0),
2695        );
2696        assert!(m.get_gesture_velocity().is_none());
2697    }
2698
2699    #[test]
2700    fn swipe_needs_velocity_above_the_configured_threshold() {
2701        // 60px in 100ms == 600 px/s > 500 px/s.
2702        let fast = dragging_manager(pos(0.0, 0.0), pos(60.0, 0.0), 100);
2703        assert!(fast.get_gesture_velocity().unwrap() > 500.0);
2704        assert!(fast.is_swipe());
2705        assert_eq!(
2706            fast.detect_swipe_direction(),
2707            Some(GestureDirection::Right)
2708        );
2709
2710        // 40px in 100ms == 400 px/s < 500 px/s.
2711        let slow = dragging_manager(pos(0.0, 0.0), pos(0.0, -40.0), 100);
2712        assert!(!slow.is_swipe());
2713        assert!(slow.detect_swipe_direction().is_none());
2714    }
2715
2716    #[test]
2717    fn gesture_velocity_with_infinite_travel_saturates_to_infinity() {
2718        let m = dragging_manager(pos(-f32::MAX, 0.0), pos(f32::MAX, 0.0), 1);
2719        let v = m.get_gesture_velocity().expect("two samples, 1ms apart");
2720        assert!(v.is_infinite(), "expected saturation to +inf, got {v}");
2721        assert!(m.is_swipe());
2722    }
2723
2724    // ------------------------------------------------- pinch / rotation
2725
2726    #[test]
2727    fn pinch_and_rotation_ignore_sequential_mouse_sessions() {
2728        // Click, release, then press-and-drag: two sessions, but the first is
2729        // ended — this must NOT be read as a two-finger gesture.
2730        let mut m = GestureAndDragManager::new();
2731        m.start_input_session(
2732            pos(0.0, 0.0),
2733            ts(0),
2734            0x01,
2735            WindowPosition::Uninitialized,
2736            pos(0.0, 0.0),
2737        );
2738        m.end_current_session();
2739        m.start_input_session(
2740            pos(200.0, 0.0),
2741            ts(10),
2742            0x01,
2743            WindowPosition::Uninitialized,
2744            pos(200.0, 0.0),
2745        );
2746        m.record_input_sample(pos(400.0, 0.0), ts(20), 0x01, pos(400.0, 0.0));
2747        assert_eq!(m.session_count(), 2);
2748        assert!(m.detect_pinch().is_none(), "an ended session is not a finger");
2749        assert!(m.detect_rotation().is_none());
2750    }
2751
2752    #[test]
2753    fn pinch_returns_none_when_the_fingers_start_on_top_of_each_other() {
2754        let mut m = GestureAndDragManager::new();
2755        m.touch_down(
2756            1,
2757            pos(100.0, 100.0),
2758            ts(0),
2759            WindowPosition::Uninitialized,
2760            pos(100.0, 100.0),
2761        );
2762        m.touch_down(
2763            2,
2764            pos(100.5, 100.0),
2765            ts(1),
2766            WindowPosition::Uninitialized,
2767            pos(100.5, 100.0),
2768        );
2769        // initial_distance 0.5 < 1.0 => division guard returns None.
2770        m.touch_move(1, pos(0.0, 100.0), ts(2), pos(0.0, 100.0));
2771        assert!(m.detect_pinch().is_none());
2772    }
2773
2774    #[test]
2775    fn pinch_below_the_scale_threshold_is_not_reported() {
2776        let mut m = GestureAndDragManager::new();
2777        m.touch_down(
2778            1,
2779            pos(100.0, 100.0),
2780            ts(0),
2781            WindowPosition::Uninitialized,
2782            pos(100.0, 100.0),
2783        );
2784        m.touch_down(
2785            2,
2786            pos(200.0, 100.0),
2787            ts(1),
2788            WindowPosition::Uninitialized,
2789            pos(200.0, 100.0),
2790        );
2791        // 100px -> 105px is a 5% change; the threshold is 10%.
2792        m.touch_move(2, pos(205.0, 100.0), ts(2), pos(205.0, 100.0));
2793        assert!(m.detect_pinch().is_none());
2794    }
2795
2796    #[test]
2797    fn pinch_in_reports_a_scale_below_one() {
2798        let mut m = GestureAndDragManager::new();
2799        m.touch_down(
2800            1,
2801            pos(0.0, 0.0),
2802            ts(0),
2803            WindowPosition::Uninitialized,
2804            pos(0.0, 0.0),
2805        );
2806        m.touch_down(
2807            2,
2808            pos(200.0, 0.0),
2809            ts(1),
2810            WindowPosition::Uninitialized,
2811            pos(200.0, 0.0),
2812        );
2813        m.touch_move(1, pos(50.0, 0.0), ts(10), pos(50.0, 0.0));
2814        m.touch_move(2, pos(150.0, 0.0), ts(11), pos(150.0, 0.0));
2815        let p = m.detect_pinch().expect("200px -> 100px is a pinch in");
2816        assert_eq!(p.initial_distance, 200.0);
2817        assert_eq!(p.current_distance, 100.0);
2818        assert_eq!(p.scale, 0.5);
2819        assert_eq!(p.center, pos(100.0, 0.0));
2820        assert_eq!(p.duration_ms, 10);
2821    }
2822
2823    #[test]
2824    fn pinch_with_infinite_coordinates_saturates_instead_of_panicking() {
2825        let mut m = GestureAndDragManager::new();
2826        m.touch_down(
2827            1,
2828            pos(0.0, 0.0),
2829            ts(0),
2830            WindowPosition::Uninitialized,
2831            pos(0.0, 0.0),
2832        );
2833        m.touch_down(
2834            2,
2835            pos(10.0, 0.0),
2836            ts(1),
2837            WindowPosition::Uninitialized,
2838            pos(10.0, 0.0),
2839        );
2840        // The spread overflows f32: MAX - (-MAX) == +inf.
2841        m.touch_move(1, pos(-f32::MAX, 0.0), ts(2), pos(-f32::MAX, 0.0));
2842        m.touch_move(2, pos(f32::MAX, 0.0), ts(3), pos(f32::MAX, 0.0));
2843        let p = m.detect_pinch().expect("an overflowing spread is still a pinch");
2844        assert!(
2845            !p.scale.is_finite(),
2846            "expected a saturated (non-finite) scale, got {}",
2847            p.scale
2848        );
2849        assert!(!p.scale.is_nan());
2850    }
2851
2852    #[test]
2853    fn pinch_and_rotation_with_nan_coordinates_never_panic() {
2854        let mut m = GestureAndDragManager::new();
2855        m.touch_down(
2856            1,
2857            pos(f32::NAN, f32::NAN),
2858            ts(0),
2859            WindowPosition::Uninitialized,
2860            pos(f32::NAN, f32::NAN),
2861        );
2862        m.touch_down(
2863            2,
2864            pos(200.0, 100.0),
2865            ts(1),
2866            WindowPosition::Uninitialized,
2867            pos(200.0, 100.0),
2868        );
2869        // Whatever the detectors decide, they must not produce a *finite*
2870        // (i.e. plausible-looking but garbage) scale or angle from NaN input.
2871        assert!(m.detect_pinch().is_none_or(|p| !p.scale.is_finite()));
2872        assert!(m
2873            .detect_rotation()
2874            .is_none_or(|r| !r.angle_radians.is_finite()));
2875    }
2876
2877    #[test]
2878    fn rotation_normalisation_terminates_for_extreme_coordinates() {
2879        // The angle-wrap `while` loops must not spin: atan2 is bounded to
2880        // [-PI, PI], so angle_diff can never be infinite.
2881        let mut m = GestureAndDragManager::new();
2882        m.touch_down(
2883            1,
2884            pos(-f32::MAX, -f32::MAX),
2885            ts(0),
2886            WindowPosition::Uninitialized,
2887            pos(0.0, 0.0),
2888        );
2889        m.touch_down(
2890            2,
2891            pos(f32::MAX, f32::MAX),
2892            ts(1),
2893            WindowPosition::Uninitialized,
2894            pos(0.0, 0.0),
2895        );
2896        m.touch_move(2, pos(-f32::MAX, f32::MAX), ts(2), pos(0.0, 0.0));
2897        let r = m.detect_rotation();
2898        assert!(r.is_none_or(|r| r.angle_radians.abs() <= core::f32::consts::PI + 1.0e-4));
2899    }
2900
2901    #[test]
2902    fn rotation_reports_the_signed_angle_between_the_two_fingers() {
2903        let mut m = GestureAndDragManager::new();
2904        m.touch_down(
2905            1,
2906            pos(0.0, 0.0),
2907            ts(0),
2908            WindowPosition::Uninitialized,
2909            pos(0.0, 0.0),
2910        );
2911        m.touch_down(
2912            2,
2913            pos(10.0, 0.0),
2914            ts(1),
2915            WindowPosition::Uninitialized,
2916            pos(10.0, 0.0),
2917        );
2918        // Finger 2 swings from +x (angle 0) to +y (angle PI/2) around finger 1.
2919        m.touch_move(2, pos(0.0, 10.0), ts(50), pos(0.0, 10.0));
2920        let r = m.detect_rotation().expect("a quarter turn is a rotation");
2921        assert!(
2922            (r.angle_radians - core::f32::consts::FRAC_PI_2).abs() < 1.0e-4,
2923            "expected ~PI/2, got {}",
2924            r.angle_radians
2925        );
2926        assert_eq!(r.center, pos(0.0, 5.0));
2927    }
2928
2929    #[test]
2930    fn rotation_below_the_angle_threshold_is_not_reported() {
2931        let mut m = GestureAndDragManager::new();
2932        m.touch_down(
2933            1,
2934            pos(0.0, 0.0),
2935            ts(0),
2936            WindowPosition::Uninitialized,
2937            pos(0.0, 0.0),
2938        );
2939        m.touch_down(
2940            2,
2941            pos(1000.0, 0.0),
2942            ts(1),
2943            WindowPosition::Uninitialized,
2944            pos(1000.0, 0.0),
2945        );
2946        // ~0.05 rad, under the 0.1 rad threshold.
2947        m.touch_move(2, pos(1000.0, 50.0), ts(2), pos(1000.0, 50.0));
2948        assert!(m.detect_rotation().is_none());
2949    }
2950
2951    // ------------------------------------------------- native gesture override
2952
2953    #[test]
2954    fn injected_native_gestures_win_over_the_in_process_detector() {
2955        let mut m = GestureAndDragManager::new();
2956
2957        m.inject_native_gesture(NativeGestureEvent::DoubleClick);
2958        assert!(m.detect_double_click(), "no sessions, but the OS said so");
2959        m.clear_native_gesture();
2960        assert!(!m.detect_double_click());
2961
2962        let lp = DetectedLongPress {
2963            position: pos(3.0, 4.0),
2964            duration_ms: u64::MAX,
2965            callback_invoked: true,
2966            session_id: u64::MAX,
2967        };
2968        m.inject_native_gesture(NativeGestureEvent::LongPress(lp));
2969        assert_eq!(m.detect_long_press(), Some(lp));
2970
2971        m.inject_native_gesture(NativeGestureEvent::Swipe(GestureDirection::Left));
2972        assert_eq!(m.detect_swipe_direction(), Some(GestureDirection::Left));
2973        assert!(
2974            !m.is_swipe(),
2975            "is_swipe() is velocity-only and ignores the native override"
2976        );
2977
2978        let pinch = DetectedPinch {
2979            scale: f32::INFINITY,
2980            center: pos(0.0, 0.0),
2981            initial_distance: 0.0,
2982            current_distance: f32::NAN,
2983            duration_ms: 0,
2984        };
2985        m.inject_native_gesture(NativeGestureEvent::Pinch(pinch));
2986        let got = m.detect_pinch().expect("native pinch is passed through");
2987        assert!(got.scale.is_infinite());
2988
2989        let rot = DetectedRotation {
2990            angle_radians: -core::f32::consts::PI,
2991            center: pos(1.0, 1.0),
2992            duration_ms: 7,
2993        };
2994        m.inject_native_gesture(NativeGestureEvent::Rotation(rot));
2995        assert_eq!(m.detect_rotation(), Some(rot));
2996
2997        m.clear_native_gesture();
2998        assert!(m.detect_long_press().is_none());
2999        assert!(m.detect_pinch().is_none());
3000        assert!(m.detect_rotation().is_none());
3001        assert!(m.detect_swipe_direction().is_none());
3002    }
3003
3004    // ------------------------------------------------- pen / pad state
3005
3006    #[test]
3007    fn pen_state_stores_extremes_verbatim_and_tracks_the_previous_state() {
3008        let mut m = GestureAndDragManager::new();
3009        m.update_pen_state(
3010            pos(1.0, 2.0),
3011            f32::NAN,
3012            (f32::INFINITY, f32::NEG_INFINITY),
3013            true,
3014            true,
3015            true,
3016            u64::MAX,
3017        );
3018        assert!(m.pen_event_pending);
3019        assert!(m.get_previous_pen_state().is_none());
3020        let pen = *m.get_pen_state().expect("pen state was just set");
3021        assert!(pen.pressure.is_nan());
3022        assert!(pen.tilt.x_tilt.is_infinite());
3023        assert!(pen.tilt.y_tilt.is_infinite());
3024        assert!(pen.in_contact && pen.is_eraser && pen.barrel_button_pressed);
3025        assert_eq!(pen.device_id, u64::MAX);
3026        // The short form must zero the extended axes.
3027        assert_eq!(pen.tangential_pressure, 0.0);
3028        assert_eq!(pen.barrel_roll_rad, 0.0);
3029        assert_eq!(pen.tool_id, 0);
3030
3031        m.clear_pen_event_pending();
3032        assert!(!m.pen_event_pending);
3033
3034        m.update_pen_state_full(
3035            pos(0.0, 0.0),
3036            1.0,
3037            (0.0, 0.0),
3038            false,
3039            false,
3040            false,
3041            0,
3042            f32::NAN,
3043            -f32::MAX,
3044            u32::MAX,
3045        );
3046        assert!(m.pen_event_pending);
3047        let prev = *m.get_previous_pen_state().expect("previous pen state kept");
3048        assert_eq!(prev.device_id, u64::MAX);
3049        let now = *m.get_pen_state().unwrap();
3050        assert!(now.tangential_pressure.is_nan());
3051        assert_eq!(now.barrel_roll_rad, -f32::MAX);
3052        assert_eq!(now.tool_id, u32::MAX);
3053
3054        m.clear_pen_state();
3055        assert!(m.get_pen_state().is_none());
3056        assert_eq!(m.get_previous_pen_state().map(|p| p.tool_id), Some(u32::MAX));
3057        assert!(m.pen_event_pending);
3058
3059        // Clearing twice must not panic and must not resurrect a state.
3060        m.clear_pen_state();
3061        assert!(m.get_pen_state().is_none());
3062        assert!(m.get_previous_pen_state().is_none());
3063    }
3064
3065    #[test]
3066    fn pad_state_round_trips_and_clears() {
3067        let mut m = GestureAndDragManager::new();
3068        assert!(m.get_pad_state().is_none());
3069        m.update_pad_state(WacomPadState {
3070            express_keys: 0b1010,
3071            touch_ring: f32::NAN,
3072            touch_ring_active: true,
3073            device_id: u64::MAX,
3074        });
3075        let pad = *m.get_pad_state().expect("pad state was just set");
3076        assert!(!pad.express_key(0));
3077        assert!(pad.express_key(1));
3078        assert!(!pad.express_key(2));
3079        assert!(pad.express_key(3));
3080        assert!(pad.touch_ring.is_nan());
3081        assert_eq!(pad.device_id, u64::MAX);
3082        m.clear_pad_state();
3083        assert!(m.get_pad_state().is_none());
3084        m.clear_pad_state();
3085        assert!(m.get_pad_state().is_none());
3086    }
3087
3088    // ------------------------------------------------- drag deltas
3089
3090    #[test]
3091    fn drag_deltas_use_window_local_and_screen_coordinates_independently() {
3092        let mut m = GestureAndDragManager::new();
3093        m.start_input_session(
3094            pos(10.0, 10.0),
3095            ts(0),
3096            0x01,
3097            WindowPosition::Initialized(PhysicalPositionI32::new(100, 100)),
3098            pos(110.0, 110.0),
3099        );
3100        // One sample: totals exist, but there is no *incremental* delta yet.
3101        assert_eq!(m.get_drag_delta(), Some((0.0, 0.0)));
3102        assert_eq!(m.get_drag_delta_screen(), Some((0.0, 0.0)));
3103        assert!(m.get_drag_delta_screen_incremental().is_none());
3104
3105        m.record_input_sample(pos(15.0, 10.0), ts(10), 0x01, pos(120.0, 130.0));
3106        m.record_input_sample(pos(20.0, 10.0), ts(20), 0x01, pos(125.0, 132.0));
3107        assert_eq!(m.get_drag_delta(), Some((10.0, 0.0)));
3108        assert_eq!(m.get_drag_delta_screen(), Some((15.0, 22.0)));
3109        assert_eq!(m.get_drag_delta_screen_incremental(), Some((5.0, 2.0)));
3110        assert_eq!(
3111            m.get_window_position_at_session_start(),
3112            Some(WindowPosition::Initialized(PhysicalPositionI32::new(
3113                100, 100
3114            )))
3115        );
3116        assert_eq!(m.get_current_mouse_position(), Some(pos(20.0, 10.0)));
3117    }
3118
3119    #[test]
3120    fn drag_deltas_at_f32_extremes_stay_finite_or_saturate() {
3121        let m = dragging_manager(pos(-f32::MAX, -f32::MAX), pos(f32::MAX, f32::MAX), 5);
3122        let (dx, dy) = m.get_drag_delta().expect("two samples");
3123        assert!(dx.is_infinite() && dy.is_infinite());
3124        let (sx, sy) = m.get_drag_delta_screen().expect("two samples");
3125        assert!(sx.is_infinite() && sy.is_infinite());
3126    }
3127
3128    // ------------------------------------------------- unified drag context
3129
3130    #[test]
3131    fn activating_a_node_drag_without_a_detected_drag_is_a_no_op() {
3132        let mut m = GestureAndDragManager::new();
3133        // No session at all.
3134        m.activate_node_drag(DomId::ROOT_ID, NodeId::new(1), DragData::new(), None);
3135        assert!(!m.is_dragging());
3136
3137        // A session that has not moved far enough to be a drag.
3138        let mut m = dragging_manager(pos(0.0, 0.0), pos(1.0, 1.0), 10);
3139        m.activate_node_drag(DomId::ROOT_ID, NodeId::new(1), DragData::new(), None);
3140        assert!(!m.is_node_drag_active());
3141        m.activate_window_drag(WindowPosition::Uninitialized, None);
3142        assert!(!m.is_window_dragging());
3143    }
3144
3145    #[test]
3146    fn node_drag_context_tracks_its_own_node_and_drop_target() {
3147        let mut m = dragging_manager(pos(0.0, 0.0), pos(100.0, 0.0), 10);
3148        let mut data = DragData::new();
3149        data.set_text("payload");
3150        m.activate_node_drag(DomId::ROOT_ID, NodeId::new(4), data, None);
3151
3152        assert!(m.is_dragging());
3153        assert!(m.is_node_drag_active());
3154        assert!(m.is_node_dragging(DomId::ROOT_ID, NodeId::new(4)));
3155        assert!(!m.is_node_dragging(DomId::ROOT_ID, NodeId::new(5)));
3156        assert!(!m.is_node_dragging(DomId { inner: 7 }, NodeId::new(4)));
3157        assert!(!m.is_window_dragging());
3158        assert!(!m.is_file_dropping());
3159        assert!(!m.is_text_selection_dragging());
3160        assert!(!m.is_scrollbar_dragging());
3161        assert!(m.get_window_drag_delta().is_none());
3162        assert!(m.get_scrollbar_scroll_offset().is_none());
3163
3164        m.update_active_drag_positions(pos(42.0, -7.0));
3165        assert_eq!(
3166            m.get_drag_context().unwrap().current_position(),
3167            pos(42.0, -7.0)
3168        );
3169
3170        m.update_drop_target(Some(azul_core::dom::DomNodeId {
3171            dom: DomId::ROOT_ID,
3172            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(9))),
3173        }));
3174        let nd = m
3175            .get_drag_context()
3176            .and_then(DragContext::as_node_drag)
3177            .expect("node drag");
3178        assert_eq!(
3179            nd.current_drop_target
3180                .into_option()
3181                .and_then(|t| t.node.into_crate_internal()),
3182            Some(NodeId::new(9))
3183        );
3184        assert_eq!(nd.drag_data.get_data("text/plain"), Some(&b"payload"[..]));
3185
3186        // Clearing the target back to None must work too.
3187        m.update_drop_target(None);
3188        assert!(m
3189            .get_drag_context()
3190            .and_then(DragContext::as_node_drag)
3191            .unwrap()
3192            .current_drop_target
3193            .into_option()
3194            .is_none());
3195
3196        // Auto-scroll only applies to text-selection drags: a no-op here.
3197        m.update_auto_scroll_direction(AutoScrollDirection::DownRight);
3198        assert!(m.is_node_drag_active());
3199
3200        let ctx = m.end_drag().expect("the drag context is returned");
3201        assert_eq!(ctx.session_id, 1);
3202        assert!(!m.is_dragging());
3203        assert!(m.end_drag().is_none());
3204    }
3205
3206    #[test]
3207    fn drop_target_and_auto_scroll_updates_without_a_drag_do_not_panic() {
3208        let mut m = GestureAndDragManager::new();
3209        m.update_drop_target(None);
3210        m.update_active_drag_positions(pos(f32::NAN, f32::INFINITY));
3211        m.update_auto_scroll_direction(AutoScrollDirection::UpLeft);
3212        m.cancel_drag();
3213        assert!(!m.is_dragging());
3214        assert!(m.get_drag_context_mut().is_none());
3215    }
3216
3217    #[test]
3218    fn text_selection_context_accepts_the_auto_scroll_direction() {
3219        let mut m = GestureAndDragManager::new();
3220        m.active_drag = Some(DragContext::text_selection(
3221            DomId::ROOT_ID,
3222            NodeId::new(2),
3223            pos(0.0, 0.0),
3224            11,
3225        ));
3226        assert!(m.is_text_selection_dragging());
3227        assert!(!m.is_node_drag_active());
3228        m.update_auto_scroll_direction(AutoScrollDirection::DownRight);
3229        assert_eq!(
3230            m.get_drag_context()
3231                .and_then(DragContext::as_text_selection)
3232                .map(|t| t.auto_scroll_direction),
3233            Some(AutoScrollDirection::DownRight)
3234        );
3235        // update_drop_target must leave a text-selection drag untouched.
3236        m.update_drop_target(None);
3237        assert!(m.is_text_selection_dragging());
3238
3239        m.cancel_drag();
3240        assert!(!m.is_dragging());
3241        assert!(!m.is_text_selection_dragging());
3242    }
3243
3244    // ------------------------------------------------- window drag maths
3245
3246    fn window_dragging_manager(initial: WindowPosition) -> GestureAndDragManager {
3247        let mut m = dragging_manager(pos(0.0, 0.0), pos(100.0, 0.0), 10);
3248        m.activate_window_drag(initial, None);
3249        assert!(m.is_window_dragging());
3250        m
3251    }
3252
3253    #[test]
3254    fn window_drag_delta_needs_an_initialized_window_position() {
3255        let m = window_dragging_manager(WindowPosition::Uninitialized);
3256        assert!(m.get_window_drag_delta().is_none());
3257        assert!(m.get_window_position_from_drag().is_none());
3258    }
3259
3260    #[test]
3261    fn window_drag_delta_is_measured_from_the_drag_start() {
3262        let mut m =
3263            window_dragging_manager(WindowPosition::Initialized(PhysicalPositionI32::new(10, 20)));
3264        m.update_active_drag_positions(pos(30.5, -20.9));
3265        // start_position is the drag's start (0,0) => delta truncates toward zero.
3266        assert_eq!(m.get_window_drag_delta(), Some((30, -20)));
3267        assert_eq!(
3268            m.get_window_position_from_drag(),
3269            Some(WindowPosition::Initialized(PhysicalPositionI32::new(40, 0)))
3270        );
3271    }
3272
3273    #[test]
3274    fn window_drag_delta_saturates_the_float_to_int_cast() {
3275        let mut m =
3276            window_dragging_manager(WindowPosition::Initialized(PhysicalPositionI32::new(0, 0)));
3277        m.update_active_drag_positions(pos(f32::MAX, -f32::MAX));
3278        assert_eq!(
3279            m.get_window_drag_delta(),
3280            Some((i32::MAX, i32::MIN)),
3281            "float->int casts must saturate, not wrap or trap"
3282        );
3283        assert_eq!(
3284            m.get_window_position_from_drag(),
3285            Some(WindowPosition::Initialized(PhysicalPositionI32::new(
3286                i32::MAX,
3287                i32::MIN
3288            )))
3289        );
3290    }
3291
3292    #[test]
3293    fn window_drag_delta_with_nan_position_is_zero_not_a_trap() {
3294        let mut m =
3295            window_dragging_manager(WindowPosition::Initialized(PhysicalPositionI32::new(3, 4)));
3296        m.update_active_drag_positions(pos(f32::NAN, f32::NAN));
3297        // `NaN as i32` is defined as 0 in Rust.
3298        assert_eq!(m.get_window_drag_delta(), Some((0, 0)));
3299        assert_eq!(
3300            m.get_window_position_from_drag(),
3301            Some(WindowPosition::Initialized(PhysicalPositionI32::new(3, 4)))
3302        );
3303    }
3304
3305    #[test]
3306    fn window_position_from_drag_at_the_i32_extremes_does_not_overflow() {
3307        // i32::MAX window origin dragged fully negative: MAX + MIN == -1.
3308        let mut m = window_dragging_manager(WindowPosition::Initialized(
3309            PhysicalPositionI32::new(i32::MAX, i32::MAX),
3310        ));
3311        m.update_active_drag_positions(pos(-f32::MAX, -f32::MAX));
3312        assert_eq!(
3313            m.get_window_position_from_drag(),
3314            Some(WindowPosition::Initialized(PhysicalPositionI32::new(-1, -1)))
3315        );
3316    }
3317
3318    // ------------------------------------------------- scrollbar drag maths
3319
3320    fn scrollbar_manager(
3321        start_offset: f32,
3322        track: f32,
3323        content: f32,
3324        viewport: f32,
3325    ) -> GestureAndDragManager {
3326        let mut m = GestureAndDragManager::new();
3327        m.active_drag = Some(DragContext::scrollbar_thumb(
3328            DomId::ROOT_ID,
3329            NodeId::new(1),
3330            ScrollbarAxis::Vertical,
3331            pos(0.0, 0.0),
3332            start_offset,
3333            track,
3334            content,
3335            viewport,
3336            1,
3337        ));
3338        m
3339    }
3340
3341    #[test]
3342    fn scrollbar_offset_scales_the_mouse_delta_and_clamps_to_the_range() {
3343        let mut m = scrollbar_manager(0.0, 100.0, 1000.0, 100.0);
3344        assert!(m.is_scrollbar_dragging());
3345        assert_eq!(m.get_scrollbar_scroll_offset(), Some(0.0));
3346
3347        // thumb = 10px, scrollable track = 90px, scrollable range = 900px.
3348        m.update_active_drag_positions(pos(0.0, 45.0));
3349        let half = m.get_scrollbar_scroll_offset().expect("scrollbar drag");
3350        assert!((half - 450.0).abs() < 0.5, "expected ~450, got {half}");
3351
3352        // Way past the end of the track: clamped to the scrollable range.
3353        m.update_active_drag_positions(pos(0.0, 1.0e9));
3354        assert_eq!(m.get_scrollbar_scroll_offset(), Some(900.0));
3355
3356        // Dragged backwards past the start: clamped to 0.
3357        m.update_active_drag_positions(pos(0.0, -1.0e9));
3358        assert_eq!(m.get_scrollbar_scroll_offset(), Some(0.0));
3359    }
3360
3361    #[test]
3362    fn scrollbar_offset_with_nothing_to_scroll_returns_the_start_offset() {
3363        // content <= viewport => scrollable range <= 0.
3364        let mut m = scrollbar_manager(42.0, 100.0, 50.0, 100.0);
3365        m.update_active_drag_positions(pos(0.0, 500.0));
3366        assert_eq!(m.get_scrollbar_scroll_offset(), Some(42.0));
3367
3368        // A zero-length track cannot be scrolled either.
3369        let mut m = scrollbar_manager(7.0, 0.0, 1000.0, 100.0);
3370        m.update_active_drag_positions(pos(0.0, 500.0));
3371        assert_eq!(m.get_scrollbar_scroll_offset(), Some(7.0));
3372    }
3373
3374    #[test]
3375    fn scrollbar_offset_with_a_nan_mouse_position_does_not_panic() {
3376        let mut m = scrollbar_manager(0.0, 100.0, 1000.0, 100.0);
3377        m.update_active_drag_positions(pos(f32::NAN, f32::NAN));
3378        let v = m.get_scrollbar_scroll_offset();
3379        assert!(
3380            v.is_some_and(f32::is_nan),
3381            "a NaN mouse position must propagate as NaN, not panic: {v:?}"
3382        );
3383    }
3384
3385    // ------------------------------------------------- event ids
3386
3387    #[cfg(feature = "std")]
3388    #[test]
3389    fn allocate_event_id_is_strictly_monotonic() {
3390        let a = allocate_event_id();
3391        let b = allocate_event_id();
3392        let c = allocate_event_id();
3393        assert!(a < b && b < c, "ids must increase: {a} {b} {c}");
3394    }
3395
3396    #[cfg(not(feature = "std"))]
3397    #[test]
3398    fn allocate_event_id_is_zero_without_std() {
3399        assert_eq!(allocate_event_id(), 0);
3400    }
3401
3402    #[cfg(feature = "std")]
3403    #[test]
3404    fn recorded_samples_get_distinct_event_ids() {
3405        let mut m = GestureAndDragManager::new();
3406        m.start_input_session(
3407            pos(0.0, 0.0),
3408            ts(0),
3409            0x01,
3410            WindowPosition::Uninitialized,
3411            pos(0.0, 0.0),
3412        );
3413        m.record_input_sample(pos(1.0, 0.0), ts(1), 0x01, pos(1.0, 0.0));
3414        let s = m.get_current_session().unwrap();
3415        assert_ne!(s.samples[0].event_id, s.samples[1].event_id);
3416        assert!(s.samples[0].event_id < s.samples[1].event_id);
3417    }
3418}