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::{collections::btree_map::BTreeMap, vec::Vec};
17#[cfg(feature = "std")]
18use std::sync::atomic::{AtomicU64, Ordering};
19
20use azul_core::{
21    dom::{DomId, NodeId, OptionDomNodeId},
22    drag::{
23        ActiveDragType, AutoScrollDirection, DragContext, DragData, DragEffect, DropEffect,
24        FileDropDrag, NodeDrag, ScrollbarAxis, ScrollbarThumbDrag, TextSelectionDrag,
25        WindowMoveDrag, WindowResizeDrag, WindowResizeEdge,
26    },
27    geom::{LogicalPosition, PhysicalPositionI32},
28    hit_test::HitTest,
29    selection::TextCursor,
30    task::{Duration as CoreDuration, Instant as CoreInstant},
31    window::WindowPosition,
32};
33use azul_css::AzString;
34use azul_css::{impl_option, impl_option_inner, StringVec};
35
36// Re-export drag types for convenience
37pub use azul_core::drag::{
38    ActiveDragType as DragType, AutoScrollDirection as AutoScroll, DragContext as UnifiedDragContext,
39    DragData as UnifiedDragData, DragEffect as UnifiedDragEffect, DropEffect as UnifiedDropEffect,
40    ScrollbarAxis as ScrollAxis, ScrollbarThumbDrag as ScrollbarDrag,
41};
42
43#[cfg(feature = "std")]
44static NEXT_EVENT_ID: AtomicU64 = AtomicU64::new(1);
45
46/// Allocate a new unique event ID
47#[cfg(feature = "std")]
48pub fn allocate_event_id() -> u64 {
49    NEXT_EVENT_ID.fetch_add(1, Ordering::Relaxed)
50}
51
52/// Allocate a new unique event ID (no_std fallback: returns 0)
53#[cfg(not(feature = "std"))]
54pub fn allocate_event_id() -> u64 {
55    0
56}
57
58/// Helper function to convert CoreDuration to milliseconds
59///
60/// CoreDuration is an enum with System (std::time::Duration) and Tick variants.
61/// We need to handle both cases for proper time calculations.
62fn duration_to_millis(duration: CoreDuration) -> u64 {
63    match duration {
64        #[cfg(feature = "std")]
65        CoreDuration::System(system_diff) => {
66            let std_duration: std::time::Duration = system_diff.into();
67            std_duration.as_millis() as u64
68        }
69        #[cfg(not(feature = "std"))]
70        CoreDuration::System(system_diff) => {
71            // Manual calculation: secs * 1000 + nanos / 1_000_000
72            system_diff.secs * 1000 + (system_diff.nanos / 1_000_000) as u64
73        }
74        CoreDuration::Tick(tick_diff) => {
75            // Assume tick = 1ms for simplicity (platform-specific)
76            tick_diff.tick_diff
77        }
78    }
79}
80
81/// Maximum number of input samples to keep in memory
82///
83/// This prevents unbounded memory growth during long drags.
84/// Older samples beyond this limit are automatically discarded.
85pub const MAX_SAMPLES_PER_SESSION: usize = 1000;
86
87/// Default timeout for clearing old gesture samples (milliseconds)
88///
89/// Samples older than this are automatically removed to prevent
90/// memory leaks and stale gesture detection.
91pub const DEFAULT_SAMPLE_TIMEOUT_MS: u64 = 2000;
92
93/// Configuration for gesture detection thresholds
94#[derive(Debug, Clone, Copy, PartialEq)]
95pub struct GestureDetectionConfig {
96    /// Minimum distance (pixels) to consider movement a drag, not a click
97    pub drag_distance_threshold: f32,
98    /// Maximum time between clicks for double-click detection (milliseconds)
99    pub double_click_time_threshold_ms: u64,
100    /// Maximum distance between clicks for double-click detection (pixels)
101    pub double_click_distance_threshold: f32,
102    /// Minimum time to hold button for long-press detection (milliseconds)
103    pub long_press_time_threshold_ms: u64,
104    /// Maximum distance to move while holding for long-press (pixels)
105    pub long_press_distance_threshold: f32,
106    /// Minimum samples needed to detect a gesture
107    pub min_samples_for_gesture: usize,
108    /// Minimum velocity for swipe detection (pixels per second)
109    pub swipe_velocity_threshold: f32,
110    /// Minimum scale change for pinch detection (e.g., 0.1 = 10% change)
111    pub pinch_scale_threshold: f32,
112    /// Minimum rotation angle for rotation detection (radians)
113    pub rotation_angle_threshold: f32,
114    /// How often to clear old samples (milliseconds)
115    pub sample_cleanup_interval_ms: u64,
116}
117
118impl Default for GestureDetectionConfig {
119    fn default() -> Self {
120        Self {
121            drag_distance_threshold: 5.0,
122            double_click_time_threshold_ms: 500,
123            double_click_distance_threshold: 5.0,
124            long_press_time_threshold_ms: 500,
125            long_press_distance_threshold: 10.0,
126            min_samples_for_gesture: 2,
127            swipe_velocity_threshold: 500.0, // 500 px/s
128            pinch_scale_threshold: 0.1,      // 10% scale change
129            rotation_angle_threshold: 0.1,   // ~5.7 degrees in radians
130            sample_cleanup_interval_ms: DEFAULT_SAMPLE_TIMEOUT_MS,
131        }
132    }
133}
134
135/// Single input sample with position and timestamp
136#[derive(Debug, Clone, PartialEq)]
137pub struct InputSample {
138    /// Position in logical coordinates
139    pub 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}
173
174impl InputSession {
175    /// Create a new input session
176    fn new(session_id: u64, first_sample: InputSample) -> Self {
177        Self {
178            samples: vec![first_sample],
179            ended: false,
180            session_id,
181        }
182    }
183
184    /// Get the first sample in this session
185    pub fn first_sample(&self) -> Option<&InputSample> {
186        self.samples.first()
187    }
188
189    /// Get the last sample in this session
190    pub fn last_sample(&self) -> Option<&InputSample> {
191        self.samples.last()
192    }
193
194    /// Get the duration of this session (first to last sample)
195    pub fn duration_ms(&self) -> Option<u64> {
196        let first = self.first_sample()?;
197        let last = self.last_sample()?;
198        let duration = last.timestamp.duration_since(&first.timestamp);
199        Some(duration_to_millis(duration))
200    }
201
202    /// Get the total distance traveled in this session
203    pub fn total_distance(&self) -> f32 {
204        if self.samples.len() < 2 {
205            return 0.0;
206        }
207
208        let mut total = 0.0;
209        for i in 1..self.samples.len() {
210            let prev = &self.samples[i - 1];
211            let curr = &self.samples[i];
212            let dx = curr.position.x - prev.position.x;
213            let dy = curr.position.y - prev.position.y;
214            total += (dx * dx + dy * dy).sqrt();
215        }
216        total
217    }
218
219    /// Get the straight-line distance from first to last sample
220    pub fn direct_distance(&self) -> Option<f32> {
221        let first = self.first_sample()?;
222        let last = self.last_sample()?;
223        let dx = last.position.x - first.position.x;
224        let dy = last.position.y - first.position.y;
225        Some((dx * dx + dy * dy).sqrt())
226    }
227}
228
229/// Result of drag detection analysis
230#[derive(Debug, Clone, Copy, PartialEq)]
231pub struct DetectedDrag {
232    /// Position where drag started
233    pub start_position: LogicalPosition,
234    /// Current/end position of drag
235    pub current_position: LogicalPosition,
236    /// Direct distance dragged (straight line, pixels)
237    pub direct_distance: f32,
238    /// Total distance dragged (following path, pixels)
239    pub total_distance: f32,
240    /// Duration of the drag (milliseconds)
241    pub duration_ms: u64,
242    /// Number of position samples recorded
243    pub sample_count: usize,
244    /// Session ID this drag belongs to
245    pub session_id: u64,
246}
247
248/// Result of long-press detection
249#[derive(Debug, Clone, Copy, PartialEq)]
250pub struct DetectedLongPress {
251    /// Position where long press is happening
252    pub position: LogicalPosition,
253    /// How long the button has been held (milliseconds)
254    pub duration_ms: u64,
255    /// Whether the callback has already been invoked for this long press
256    pub callback_invoked: bool,
257    /// Session ID this long press belongs to
258    pub session_id: u64,
259}
260
261/// Primary direction of a gesture
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub enum GestureDirection {
264    Up,
265    Down,
266    Left,
267    Right,
268}
269
270/// Result of pinch gesture detection
271#[derive(Debug, Clone, Copy, PartialEq)]
272pub struct DetectedPinch {
273    /// Scale factor (< 1.0 for pinch in, > 1.0 for pinch out)
274    pub scale: f32,
275    /// Center point of the pinch gesture
276    pub center: LogicalPosition,
277    /// Initial distance between touch points
278    pub initial_distance: f32,
279    /// Current distance between touch points
280    pub current_distance: f32,
281    /// Duration of pinch (milliseconds)
282    pub duration_ms: u64,
283}
284
285/// Result of rotation gesture detection
286#[derive(Debug, Clone, Copy, PartialEq)]
287pub struct DetectedRotation {
288    /// Rotation angle in radians (positive = clockwise)
289    pub angle_radians: f32,
290    /// Center point of rotation
291    pub center: LogicalPosition,
292    /// Duration of rotation (milliseconds)
293    pub duration_ms: u64,
294}
295
296// NOTE: NodeDragState, WindowDragState, FileDropState, DropEffect, DragData, DragEffect
297// are now defined in azul_core::drag and imported above.
298// The old types are kept as type aliases for backwards compatibility.
299
300/// State of an active node drag (after detection)
301/// DEPRECATED: Use `DragContext` with `ActiveDragType::Node` instead.
302pub type NodeDragState = NodeDrag;
303
304/// State of window being dragged (titlebar drag)
305/// DEPRECATED: Use `DragContext` with `ActiveDragType::WindowMove` instead.
306pub type WindowDragState = WindowMoveDrag;
307
308/// State of file(s) being dragged from OS over the window
309/// DEPRECATED: Use `DragContext` with `ActiveDragType::FileDrop` instead.
310pub type FileDropState = FileDropDrag;
311
312/// State of pen/stylus input
313#[derive(Debug, Clone, Copy, PartialEq)]
314#[repr(C)]
315pub struct PenState {
316    /// Current pen position
317    pub position: LogicalPosition,
318    /// Current pressure (0.0 to 1.0)
319    pub pressure: f32,
320    /// Current tilt angles (x_tilt, y_tilt) in degrees
321    pub tilt: crate::callbacks::PenTilt,
322    /// Whether pen is in contact with surface
323    pub in_contact: bool,
324    /// Whether pen is inverted (eraser mode)
325    pub is_eraser: bool,
326    /// Whether barrel button is pressed
327    pub barrel_button_pressed: bool,
328    /// Unique identifier for this pen device
329    pub device_id: u64,
330}
331
332impl_option!(PenState, OptionPenState, [Debug, Clone, Copy, PartialEq]);
333
334impl Default for PenState {
335    fn default() -> Self {
336        Self {
337            position: LogicalPosition::zero(),
338            pressure: 0.0,
339            tilt: crate::callbacks::PenTilt {
340                x_tilt: 0.0,
341                y_tilt: 0.0,
342            },
343            in_contact: false,
344            is_eraser: false,
345            barrel_button_pressed: false,
346            device_id: 0,
347        }
348    }
349}
350
351/// Manager for multi-frame gestures and drag operations
352///
353/// This collects raw input samples and analyzes them to detect gestures.
354/// Designed for testability and clear separation of input collection
355/// vs. detection.
356///
357/// ## Unified Drag System
358///
359/// The manager now uses `DragContext` to unify all drag types:
360/// - `active_drag`: The unified drag context (replaces individual drag states)
361///
362/// For backwards compatibility, the old `node_drag`, `window_drag`, `file_drop`
363/// fields are still accessible but deprecated.
364#[derive(Debug, Clone, PartialEq)]
365pub struct GestureAndDragManager {
366    /// Configuration for gesture detection
367    pub config: GestureDetectionConfig,
368    /// All recorded input sessions (multiple button press sequences)
369    pub input_sessions: Vec<InputSession>,
370    /// **NEW**: Unified drag context for all drag types
371    pub active_drag: Option<DragContext>,
372    /// Current pen/stylus state
373    pub pen_state: Option<PenState>,
374    /// Session IDs where long press callback has been invoked
375    long_press_callbacks_invoked: Vec<u64>,
376    /// Counter for generating unique session IDs
377    next_session_id: u64,
378}
379
380/// Type alias for backwards compatibility
381pub type GestureManager = GestureAndDragManager;
382
383impl Default for GestureAndDragManager {
384    fn default() -> Self {
385        Self::new()
386    }
387}
388
389impl GestureAndDragManager {
390    /// Create a new gesture and drag manager
391    pub fn new() -> Self {
392        Self {
393            config: GestureDetectionConfig::default(),
394            input_sessions: Vec::new(),
395            next_session_id: 1,
396            active_drag: None,
397            pen_state: None,
398            long_press_callbacks_invoked: Vec::new(),
399        }
400    }
401
402    /// Create with custom configuration
403    pub fn with_config(config: GestureDetectionConfig) -> Self {
404        Self {
405            config,
406            ..Self::new()
407        }
408    }
409
410    // Input Recording Methods (called from event loop / system timer)
411
412    /// Start a new input session (mouse button pressed down)
413    ///
414    /// This begins recording samples for gesture detection.
415    /// Call this when receiving mouse button down event.
416    ///
417    /// Returns the session ID for this new session.
418    pub fn start_input_session(
419        &mut self,
420        position: LogicalPosition,
421        timestamp: CoreInstant,
422        button_state: u8,
423    ) -> u64 {
424        self.start_input_session_with_pen(
425            position,
426            timestamp,
427            button_state,
428            allocate_event_id(),
429            0.5,        // default pressure for mouse
430            (0.0, 0.0), // no tilt for mouse
431            (0.0, 0.0), // no touch radius for mouse
432        )
433    }
434
435    /// Start a new input session with pen/touch data
436    pub fn start_input_session_with_pen(
437        &mut self,
438        position: LogicalPosition,
439        timestamp: CoreInstant,
440        button_state: u8,
441        event_id: u64,
442        pressure: f32,
443        tilt: (f32, f32),
444        touch_radius: (f32, f32),
445    ) -> u64 {
446        // Clear all ended sessions before starting a new one.
447        // This prevents multiple sequential mouse clicks from being
448        // misinterpreted as multi-touch gestures (pinch, rotate).
449        // Only keep sessions that are still active (not ended).
450        self.input_sessions.retain(|session| !session.ended);
451
452        let session_id = self.next_session_id;
453        self.next_session_id += 1;
454
455        let sample = InputSample {
456            position,
457            timestamp,
458            button_state,
459            event_id,
460            pressure,
461            tilt,
462            touch_radius,
463        };
464
465        let session = InputSession::new(session_id, sample);
466        self.input_sessions.push(session);
467
468        session_id
469    }
470
471    /// Record an input sample to the current session
472    ///
473    /// Call this on every mouse move event while button is pressed,
474    /// and also periodically from a system timer to track long presses.
475    ///
476    /// Returns true if sample was recorded, false if no active session.
477    pub fn record_input_sample(
478        &mut self,
479        position: LogicalPosition,
480        timestamp: CoreInstant,
481        button_state: u8,
482    ) -> bool {
483        self.record_input_sample_with_pen(
484            position,
485            timestamp,
486            button_state,
487            allocate_event_id(),
488            0.5,        // default pressure for mouse
489            (0.0, 0.0), // no tilt for mouse
490            (0.0, 0.0), // no touch radius for mouse
491        )
492    }
493
494    /// Record an input sample with pen/touch data
495    pub fn record_input_sample_with_pen(
496        &mut self,
497        position: LogicalPosition,
498        timestamp: CoreInstant,
499        button_state: u8,
500        event_id: u64,
501        pressure: f32,
502        tilt: (f32, f32),
503        touch_radius: (f32, f32),
504    ) -> bool {
505        let session = match self.input_sessions.last_mut() {
506            Some(s) => s,
507            None => return false,
508        };
509
510        if session.ended {
511            return false;
512        }
513
514        // Enforce max samples limit
515        if session.samples.len() >= MAX_SAMPLES_PER_SESSION {
516            // Remove oldest samples, keeping the most recent ones
517            let remove_count = session.samples.len() - MAX_SAMPLES_PER_SESSION + 100;
518            session.samples.drain(0..remove_count);
519        }
520
521        session.samples.push(InputSample {
522            position,
523            timestamp,
524            button_state,
525            event_id,
526            pressure,
527            tilt,
528            touch_radius,
529        });
530
531        true
532    }
533
534    /// End the current input session (mouse button released)
535    ///
536    /// Call this when receiving mouse button up event.
537    /// The session is kept for analysis but marked as ended.
538    pub fn end_current_session(&mut self) {
539        if let Some(session) = self.input_sessions.last_mut() {
540            session.ended = true;
541        }
542    }
543
544    /// Clear old input sessions that have timed out
545    ///
546    /// Call this periodically (e.g., every frame) to prevent memory leaks.
547    /// Sessions older than `config.sample_cleanup_interval_ms` are removed.
548    pub fn clear_old_sessions(&mut self, current_time: CoreInstant) {
549        self.input_sessions.retain(|session| {
550            if let Some(last_sample) = session.last_sample() {
551                let duration = current_time.duration_since(&last_sample.timestamp);
552                let age_ms = duration_to_millis(duration);
553                age_ms < self.config.sample_cleanup_interval_ms
554            } else {
555                false
556            }
557        });
558
559        // Also clear long press callback tracking for removed sessions
560        let valid_session_ids: Vec<u64> =
561            self.input_sessions.iter().map(|s| s.session_id).collect();
562
563        self.long_press_callbacks_invoked
564            .retain(|id| valid_session_ids.contains(id));
565    }
566
567    /// Clear all input sessions
568    ///
569    /// Call this when you want to reset all gesture detection state.
570    pub fn clear_all_sessions(&mut self) {
571        self.input_sessions.clear();
572        self.long_press_callbacks_invoked.clear();
573    }
574
575    /// Update pen/stylus state
576    ///
577    /// Call this when receiving pen events from the platform.
578    pub fn update_pen_state(
579        &mut self,
580        position: LogicalPosition,
581        pressure: f32,
582        tilt: (f32, f32),
583        in_contact: bool,
584        is_eraser: bool,
585        barrel_button_pressed: bool,
586        device_id: u64,
587    ) {
588        self.pen_state = Some(PenState {
589            position,
590            pressure,
591            tilt: crate::callbacks::PenTilt {
592                x_tilt: tilt.0,
593                y_tilt: tilt.1,
594            },
595            in_contact,
596            is_eraser,
597            barrel_button_pressed,
598            device_id,
599        });
600    }
601
602    /// Clear pen state (when pen leaves proximity)
603    pub fn clear_pen_state(&mut self) {
604        self.pen_state = None;
605    }
606
607    /// Get current pen state (read-only)
608    pub fn get_pen_state(&self) -> Option<&PenState> {
609        self.pen_state.as_ref()
610    }
611
612    // Gesture Detection Methods (query state without mutation)
613
614    /// Detect if current input represents a drag gesture
615    ///
616    /// Returns Some(DetectedDrag) if a drag is detected based on distance threshold.
617    pub fn detect_drag(&self) -> Option<DetectedDrag> {
618        let session = self.get_current_session()?;
619
620        if session.samples.len() < self.config.min_samples_for_gesture {
621            return None;
622        }
623
624        let direct_distance = session.direct_distance()?;
625
626        if direct_distance >= self.config.drag_distance_threshold {
627            let first = session.first_sample()?;
628            let last = session.last_sample()?;
629
630            Some(DetectedDrag {
631                start_position: first.position,
632                current_position: last.position,
633                direct_distance,
634                total_distance: session.total_distance(),
635                duration_ms: session.duration_ms()?,
636                sample_count: session.samples.len(),
637                session_id: session.session_id,
638            })
639        } else {
640            None
641        }
642    }
643
644    /// Detect if current input represents a long press
645    ///
646    /// Returns Some(DetectedLongPress) if button has been held long enough
647    /// without moving much.
648    pub fn detect_long_press(&self) -> Option<DetectedLongPress> {
649        let session = self.get_current_session()?;
650
651        if session.ended {
652            return None; // Can't be long press if button already released
653        }
654
655        let duration_ms = session.duration_ms()?;
656
657        if duration_ms < self.config.long_press_time_threshold_ms {
658            return None;
659        }
660
661        let distance = session.direct_distance()?;
662
663        if distance <= self.config.long_press_distance_threshold {
664            let first = session.first_sample()?;
665            let callback_invoked = self
666                .long_press_callbacks_invoked
667                .contains(&session.session_id);
668
669            Some(DetectedLongPress {
670                position: first.position,
671                duration_ms,
672                callback_invoked,
673                session_id: session.session_id,
674            })
675        } else {
676            None
677        }
678    }
679
680    /// Mark long press callback as invoked for a session
681    ///
682    /// Call this after invoking the long press callback to prevent
683    /// repeated invocations.
684    pub fn mark_long_press_callback_invoked(&mut self, session_id: u64) {
685        if !self.long_press_callbacks_invoked.contains(&session_id) {
686            self.long_press_callbacks_invoked.push(session_id);
687        }
688    }
689
690    /// Detect if last two sessions form a double-click.
691    ///
692    /// Returns true if timing and distance match double-click criteria.
693    pub fn detect_double_click(&self) -> bool {
694        let sessions = &self.input_sessions;
695        if sessions.len() < 2 {
696            return false;
697        }
698
699        let prev_session = &sessions[sessions.len() - 2];
700        let last_session = &sessions[sessions.len() - 1];
701
702        // Both sessions must have ended (button released)
703        if !prev_session.ended || !last_session.ended {
704            return false;
705        }
706
707        let prev_first = prev_session.first_sample();
708        let last_first = last_session.first_sample();
709        let (prev_first, last_first) = match (prev_first, last_first) {
710            (Some(p), Some(l)) => (p, l),
711            _ => return false,
712        };
713
714        let duration = last_first.timestamp.duration_since(&prev_first.timestamp);
715        let time_delta_ms = duration_to_millis(duration);
716        if time_delta_ms > self.config.double_click_time_threshold_ms {
717            return false;
718        }
719
720        let dx = last_first.position.x - prev_first.position.x;
721        let dy = last_first.position.y - prev_first.position.y;
722        let distance = (dx * dx + dy * dy).sqrt();
723
724        distance < self.config.double_click_distance_threshold
725    }
726
727    /// Get the primary direction of current drag.
728    pub fn get_drag_direction(&self) -> Option<GestureDirection> {
729        let session = self.get_current_session()?;
730        let first = session.first_sample()?;
731        let last = session.last_sample()?;
732
733        let dx = last.position.x - first.position.x;
734        let dy = last.position.y - first.position.y;
735
736        let direction = if dx.abs() > dy.abs() {
737            if dx > 0.0 {
738                GestureDirection::Right
739            } else {
740                GestureDirection::Left
741            }
742        } else {
743            if dy > 0.0 {
744                GestureDirection::Down
745            } else {
746                GestureDirection::Up
747            }
748        };
749        Some(direction)
750    }
751
752    /// Get average velocity of current gesture (pixels per second)
753    pub fn get_gesture_velocity(&self) -> Option<f32> {
754        let session = self.get_current_session()?;
755
756        if session.samples.len() < 2 {
757            return None;
758        }
759
760        let total_distance = session.total_distance();
761        let duration_ms = session.duration_ms()?;
762
763        if duration_ms == 0 {
764            return None;
765        }
766
767        let duration_secs = duration_ms as f32 / 1000.0;
768        Some(total_distance / duration_secs)
769    }
770
771    /// Check if current gesture is a swipe (fast directional movement).
772    pub fn is_swipe(&self) -> bool {
773        self.get_gesture_velocity()
774            .map(|v| v >= self.config.swipe_velocity_threshold)
775            .unwrap_or(false)
776    }
777
778    /// Detect swipe with specific direction
779    ///
780    /// Returns Some(dir) if gesture is a fast swipe in a clear direction
781    pub fn detect_swipe_direction(&self) -> Option<GestureDirection> {
782        // Must be a fast swipe first
783        if !self.is_swipe() {
784            return None;
785        }
786
787        // Get direction
788        self.get_drag_direction()
789    }
790
791    /// Detect pinch gesture (two-touch zoom in/out)
792    ///
793    /// Returns Some if two touch points are active and distance is changing
794    /// significantly. Scale < 1.0 = pinch in, scale > 1.0 = pinch out.
795    pub fn detect_pinch(&self) -> Option<DetectedPinch> {
796        // Need at least two active sessions for pinch
797        if self.input_sessions.len() < 2 {
798            return None;
799        }
800
801        // Get last two sessions (most recent touches)
802        let session1 = &self.input_sessions[self.input_sessions.len() - 2];
803        let session2 = &self.input_sessions[self.input_sessions.len() - 1];
804
805        // Both must have samples
806        let first1 = session1.first_sample()?;
807        let first2 = session2.first_sample()?;
808        let last1 = session1.last_sample()?;
809        let last2 = session2.last_sample()?;
810
811        // Calculate initial distance between touches
812        let dx_initial = first2.position.x - first1.position.x;
813        let dy_initial = first2.position.y - first1.position.y;
814        let initial_distance = (dx_initial * dx_initial + dy_initial * dy_initial).sqrt();
815
816        // Calculate current distance
817        let dx_current = last2.position.x - last1.position.x;
818        let dy_current = last2.position.y - last1.position.y;
819        let current_distance = (dx_current * dx_current + dy_current * dy_current).sqrt();
820
821        // Avoid division by zero
822        if initial_distance < 1.0 {
823            return None;
824        }
825
826        // Calculate scale factor
827        let scale = current_distance / initial_distance;
828
829        // Check if scale change is significant (threshold from config)
830        let scale_threshold = 1.0 + self.config.pinch_scale_threshold;
831        if scale > 1.0 / scale_threshold && scale < scale_threshold {
832            return None; // Change too small
833        }
834
835        // Calculate center point
836        let center = LogicalPosition {
837            x: (last1.position.x + last2.position.x) / 2.0,
838            y: (last1.position.y + last2.position.y) / 2.0,
839        };
840
841        // Calculate duration
842        let duration = last1.timestamp.duration_since(&first1.timestamp);
843        let duration_ms = duration_to_millis(duration);
844
845        Some(DetectedPinch {
846            scale,
847            center,
848            initial_distance,
849            current_distance,
850            duration_ms,
851        })
852    }
853
854    /// Detect rotation gesture (two-touch rotate)
855    ///
856    /// Returns Some if two touch points are rotating around center.
857    /// Positive angle = clockwise, negative = counterclockwise.
858    pub fn detect_rotation(&self) -> Option<DetectedRotation> {
859        // Need at least two active sessions
860        if self.input_sessions.len() < 2 {
861            return None;
862        }
863
864        // Get last two sessions
865        let session1 = &self.input_sessions[self.input_sessions.len() - 2];
866        let session2 = &self.input_sessions[self.input_sessions.len() - 1];
867
868        // Both must have samples
869        let first1 = session1.first_sample()?;
870        let first2 = session2.first_sample()?;
871        let last1 = session1.last_sample()?;
872        let last2 = session2.last_sample()?;
873
874        // Calculate center (average of both touches)
875        let center = LogicalPosition {
876            x: (last1.position.x + last2.position.x) / 2.0,
877            y: (last1.position.y + last2.position.y) / 2.0,
878        };
879
880        // Calculate initial angle between touches
881        let dx_initial = first2.position.x - first1.position.x;
882        let dy_initial = first2.position.y - first1.position.y;
883        let initial_angle = dy_initial.atan2(dx_initial);
884
885        // Calculate current angle
886        let dx_current = last2.position.x - last1.position.x;
887        let dy_current = last2.position.y - last1.position.y;
888        let current_angle = dy_current.atan2(dx_current);
889
890        // Calculate angle difference (normalized to -π to π)
891        let mut angle_diff = current_angle - initial_angle;
892
893        // Normalize angle to -π to π range
894        const PI: f32 = core::f32::consts::PI;
895        while angle_diff > PI {
896            angle_diff -= 2.0 * PI;
897        }
898        while angle_diff < -PI {
899            angle_diff += 2.0 * PI;
900        }
901
902        // Check if rotation is significant (threshold from config)
903        if angle_diff.abs() < self.config.rotation_angle_threshold {
904            return None;
905        }
906
907        // Calculate duration
908        let duration = last1.timestamp.duration_since(&first1.timestamp);
909        let duration_ms = duration_to_millis(duration);
910
911        Some(DetectedRotation {
912            angle_radians: angle_diff,
913            center,
914            duration_ms,
915        })
916    }
917
918    /// Get the current active input session (if any)
919    pub fn get_current_session(&self) -> Option<&InputSession> {
920        self.input_sessions.last()
921    }
922
923    /// Get current mouse position from latest sample
924    pub fn get_current_mouse_position(&self) -> Option<LogicalPosition> {
925        self.get_current_session()
926            .and_then(|s| s.last_sample())
927            .map(|sample| sample.position)
928    }
929
930    // ========================================================================
931    // UNIFIED DRAG CONTEXT API (NEW)
932    // ========================================================================
933
934    /// Get the active drag context (if any)
935    pub fn get_drag_context(&self) -> Option<&DragContext> {
936        self.active_drag.as_ref()
937    }
938
939    /// Get the active drag context mutably (if any)
940    pub fn get_drag_context_mut(&mut self) -> Option<&mut DragContext> {
941        self.active_drag.as_mut()
942    }
943
944    /// Activate a text selection drag
945    pub fn activate_text_selection_drag(
946        &mut self,
947        dom_id: DomId,
948        anchor_ifc_node: NodeId,
949        start_mouse_position: LogicalPosition,
950    ) {
951        let session_id = self.current_session_id().unwrap_or(0);
952        self.active_drag = Some(DragContext::text_selection(
953            dom_id,
954            anchor_ifc_node,
955            start_mouse_position,
956            session_id,
957        ));
958    }
959
960    /// Activate a scrollbar thumb drag
961    pub fn activate_scrollbar_drag(
962        &mut self,
963        scroll_container_node: NodeId,
964        axis: ScrollbarAxis,
965        start_mouse_position: LogicalPosition,
966        start_scroll_offset: f32,
967        track_length_px: f32,
968        content_length_px: f32,
969        viewport_length_px: f32,
970    ) {
971        let session_id = self.current_session_id().unwrap_or(0);
972        self.active_drag = Some(DragContext::scrollbar_thumb(
973            scroll_container_node,
974            axis,
975            start_mouse_position,
976            start_scroll_offset,
977            track_length_px,
978            content_length_px,
979            viewport_length_px,
980            session_id,
981        ));
982    }
983
984    /// Activate a node drag-and-drop
985    pub fn activate_node_drag(
986        &mut self,
987        dom_id: DomId,
988        node_id: NodeId,
989        drag_data: DragData,
990        _start_hit_test: Option<HitTest>,
991    ) {
992        if let Some(detected) = self.detect_drag() {
993            self.active_drag = Some(DragContext::node_drag(
994                dom_id,
995                node_id,
996                detected.start_position,
997                drag_data,
998                detected.session_id,
999            ));
1000        }
1001    }
1002
1003    /// Activate a window move drag (titlebar)
1004    pub fn activate_window_drag(
1005        &mut self,
1006        initial_window_position: WindowPosition,
1007        _start_hit_test: Option<HitTest>,
1008    ) {
1009        if let Some(detected) = self.detect_drag() {
1010            self.active_drag = Some(DragContext::window_move(
1011                detected.start_position,
1012                initial_window_position,
1013                detected.session_id,
1014            ));
1015        }
1016    }
1017
1018    /// Start file drop from OS
1019    pub fn start_file_drop(&mut self, files: Vec<AzString>, position: LogicalPosition) {
1020        let session_id = self.current_session_id().unwrap_or(0);
1021        self.active_drag = Some(DragContext::file_drop(files, position, session_id));
1022    }
1023
1024    /// Update positions for active drag (call on mouse move)
1025    pub fn update_active_drag_positions(&mut self, position: LogicalPosition) {
1026        if let Some(ref mut drag) = self.active_drag {
1027            drag.update_position(position);
1028        }
1029    }
1030
1031    /// Update drop target for node or file drag
1032    pub fn update_drop_target(&mut self, target: Option<azul_core::dom::DomNodeId>) {
1033        if let Some(ref mut drag) = self.active_drag {
1034            match &mut drag.drag_type {
1035                ActiveDragType::Node(ref mut node_drag) => {
1036                    node_drag.current_drop_target = target.into();
1037                }
1038                ActiveDragType::FileDrop(ref mut file_drop) => {
1039                    file_drop.drop_target = target.into();
1040                }
1041                _ => {}
1042            }
1043        }
1044    }
1045
1046    /// Update auto-scroll direction for text selection drag
1047    pub fn update_auto_scroll_direction(&mut self, direction: AutoScrollDirection) {
1048        if let Some(ref mut drag) = self.active_drag {
1049            if let Some(text_drag) = drag.as_text_selection_mut() {
1050                text_drag.auto_scroll_direction = direction;
1051            }
1052        }
1053    }
1054
1055    /// End the current drag and return the context
1056    pub fn end_drag(&mut self) -> Option<DragContext> {
1057        self.active_drag.take()
1058    }
1059
1060    /// Cancel the current drag
1061    pub fn cancel_drag(&mut self) {
1062        if let Some(ref mut drag) = self.active_drag {
1063            drag.cancelled = true;
1064        }
1065        self.active_drag = None;
1066    }
1067
1068    // ========================================================================
1069    // QUERY METHODS
1070    // ========================================================================
1071
1072    /// Check if any drag operation is in progress
1073    pub fn is_dragging(&self) -> bool {
1074        self.active_drag.is_some()
1075    }
1076
1077    /// Check if a text selection drag is active
1078    pub fn is_text_selection_dragging(&self) -> bool {
1079        self.active_drag.as_ref().is_some_and(|d| d.is_text_selection())
1080    }
1081
1082    /// Check if a scrollbar thumb drag is active
1083    pub fn is_scrollbar_dragging(&self) -> bool {
1084        self.active_drag.as_ref().is_some_and(|d| d.is_scrollbar_thumb())
1085    }
1086
1087    /// Check if a node drag is active
1088    pub fn is_node_dragging_any(&self) -> bool {
1089        self.active_drag.as_ref().is_some_and(|d| d.is_node_drag())
1090    }
1091
1092    /// Check if a specific node is being dragged
1093    pub fn is_node_dragging(&self, dom_id: DomId, node_id: NodeId) -> bool {
1094        self.active_drag.as_ref().is_some_and(|d| {
1095            if let Some(node_drag) = d.as_node_drag() {
1096                node_drag.dom_id == dom_id && node_drag.node_id == node_id
1097            } else {
1098                false
1099            }
1100        })
1101    }
1102
1103    /// Check if window drag is active
1104    pub fn is_window_dragging(&self) -> bool {
1105        self.active_drag.as_ref().is_some_and(|d| d.is_window_move())
1106    }
1107
1108    /// Check if file drop is active
1109    pub fn is_file_dropping(&self) -> bool {
1110        self.active_drag.as_ref().is_some_and(|d| d.is_file_drop())
1111    }
1112
1113    /// Get number of active input sessions
1114    pub fn session_count(&self) -> usize {
1115        self.input_sessions.len()
1116    }
1117
1118    /// Get current session ID (if any)
1119    pub fn current_session_id(&self) -> Option<u64> {
1120        self.get_current_session().map(|s| s.session_id)
1121    }
1122
1123    // ========================================================================
1124    // BACKWARDS COMPATIBILITY (DEPRECATED)
1125    // ========================================================================
1126
1127    /// Get current node drag state (if any)
1128    /// DEPRECATED: Use `get_drag_context()` and check for `ActiveDragType::Node`
1129    pub fn get_node_drag(&self) -> Option<&NodeDrag> {
1130        self.active_drag.as_ref().and_then(|d| d.as_node_drag())
1131    }
1132
1133    /// Get current window drag state (if any)
1134    /// DEPRECATED: Use `get_drag_context()` and check for `ActiveDragType::WindowMove`
1135    pub fn get_window_drag(&self) -> Option<&WindowMoveDrag> {
1136        self.active_drag.as_ref().and_then(|d| d.as_window_move())
1137    }
1138
1139    /// Get current file drop state (if any)
1140    /// DEPRECATED: Use `get_drag_context()` and check for `ActiveDragType::FileDrop`
1141    pub fn get_file_drop(&self) -> Option<&FileDropDrag> {
1142        self.active_drag.as_ref().and_then(|d| d.as_file_drop())
1143    }
1144
1145    /// End node drag (returns None - use end_drag() instead)
1146    /// DEPRECATED: Use `end_drag()` instead
1147    pub fn end_node_drag(&mut self) -> Option<DragContext> {
1148        if self.active_drag.as_ref().is_some_and(|d| d.is_node_drag()) {
1149            self.end_drag()
1150        } else {
1151            None
1152        }
1153    }
1154
1155    /// End window drag (returns None - use end_drag() instead)
1156    /// DEPRECATED: Use `end_drag()` instead
1157    pub fn end_window_drag(&mut self) -> Option<DragContext> {
1158        if self.active_drag.as_ref().is_some_and(|d| d.is_window_move()) {
1159            self.end_drag()
1160        } else {
1161            None
1162        }
1163    }
1164
1165    /// End file drop (returns None - use end_drag() instead)
1166    /// DEPRECATED: Use `end_drag()` instead
1167    pub fn end_file_drop(&mut self) -> Option<DragContext> {
1168        if self.active_drag.as_ref().is_some_and(|d| d.is_file_drop()) {
1169            self.end_drag()
1170        } else {
1171            None
1172        }
1173    }
1174
1175    /// Cancel file drop
1176    /// DEPRECATED: Use `cancel_drag()` instead
1177    pub fn cancel_file_drop(&mut self) {
1178        if self.active_drag.as_ref().is_some_and(|d| d.is_file_drop()) {
1179            self.cancel_drag();
1180        }
1181    }
1182
1183    // ========================================================================
1184    // WINDOW DRAG HELPER METHODS
1185    // ========================================================================
1186
1187    /// Calculate window position delta from current drag state
1188    ///
1189    /// Returns (delta_x, delta_y) to apply to window position.
1190    /// Returns None if no window drag is active or drag hasn't moved.
1191    pub fn get_window_drag_delta(&self) -> Option<(i32, i32)> {
1192        let drag = self.active_drag.as_ref()?.as_window_move()?;
1193
1194        let delta_x = drag.current_position.x - drag.start_position.x;
1195        let delta_y = drag.current_position.y - drag.start_position.y;
1196
1197        match drag.initial_window_position {
1198            WindowPosition::Initialized(_initial_pos) => Some((delta_x as i32, delta_y as i32)),
1199            _ => None,
1200        }
1201    }
1202
1203    /// Get the new window position based on current drag
1204    ///
1205    /// Returns the absolute window position to set.
1206    pub fn get_window_position_from_drag(&self) -> Option<WindowPosition> {
1207        let drag = self.active_drag.as_ref()?.as_window_move()?;
1208
1209        let delta_x = drag.current_position.x - drag.start_position.x;
1210        let delta_y = drag.current_position.y - drag.start_position.y;
1211
1212        match drag.initial_window_position {
1213            WindowPosition::Initialized(initial_pos) => {
1214                Some(WindowPosition::Initialized(PhysicalPositionI32::new(
1215                    initial_pos.x + delta_x as i32,
1216                    initial_pos.y + delta_y as i32,
1217                )))
1218            }
1219            _ => None,
1220        }
1221    }
1222
1223    /// Calculate the new scroll offset for scrollbar thumb drag
1224    pub fn get_scrollbar_scroll_offset(&self) -> Option<f32> {
1225        self.active_drag.as_ref()?.calculate_scrollbar_scroll_offset()
1226    }
1227}