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