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 (window-local, Y=0 at top of window)
139    pub position: LogicalPosition,
140    /// Position in virtual screen coordinates (Y=0 at top of primary monitor).
141    ///
142    /// Computed as `window_position + position` at the time the sample is recorded.
143    /// This is stable during window drags because `window_pos + cursor_local`
144    /// always equals the true screen position, even when the window moves.
145    ///
146    /// All coordinates are in logical pixels (HiDPI-independent).
147    /// On Wayland, this is an estimate (compositor does not expose global position).
148    pub screen_position: LogicalPosition,
149    /// Timestamp when this sample was recorded (from ExternalSystemCallbacks)
150    pub timestamp: CoreInstant,
151    /// Mouse button state (bitfield: 0x01 = left, 0x02 = right, 0x04 = middle)
152    pub button_state: u8,
153    /// Unique, monotonic event ID for ordering (atomic counter)
154    pub event_id: u64,
155    /// Pen/stylus pressure (0.0 to 1.0, 0.5 = default for mouse)
156    pub pressure: f32,
157    /// Pen/stylus tilt angles in degrees (x_tilt, y_tilt)
158    /// Range: typically -90.0 to 90.0, (0.0, 0.0) = perpendicular
159    pub tilt: (f32, f32),
160    /// Touch contact radius in logical pixels (width, height)
161    /// For mouse input, this is (0.0, 0.0)
162    pub touch_radius: (f32, f32),
163}
164
165impl_option!(
166    InputSample,
167    OptionInputSample,
168    copy = false,
169    [Debug, Clone, PartialEq]
170);
171
172/// A sequence of input samples forming one button press session
173#[derive(Debug, Clone, PartialEq)]
174pub struct InputSession {
175    /// All recorded samples for this session
176    pub samples: Vec<InputSample>,
177    /// Whether this session has ended (button released)
178    pub ended: bool,
179    /// Session ID for tracking (incremental counter)
180    pub session_id: u64,
181    /// Window position at the time this session started (mouse-down).
182    /// Used by titlebar drag callbacks to compute new window position.
183    pub window_position_at_start: azul_core::window::WindowPosition,
184}
185
186impl InputSession {
187    /// Create a new input session
188    fn new(session_id: u64, first_sample: InputSample, window_position: azul_core::window::WindowPosition) -> Self {
189        Self {
190            samples: vec![first_sample],
191            ended: false,
192            session_id,
193            window_position_at_start: window_position,
194        }
195    }
196
197    /// Get the first sample in this session
198    pub fn first_sample(&self) -> Option<&InputSample> {
199        self.samples.first()
200    }
201
202    /// Get the last sample in this session
203    pub fn last_sample(&self) -> Option<&InputSample> {
204        self.samples.last()
205    }
206
207    /// Get the duration of this session (first to last sample)
208    pub fn duration_ms(&self) -> Option<u64> {
209        let first = self.first_sample()?;
210        let last = self.last_sample()?;
211        let duration = last.timestamp.duration_since(&first.timestamp);
212        Some(duration_to_millis(duration))
213    }
214
215    /// Get the total distance traveled in this session
216    pub fn total_distance(&self) -> f32 {
217        if self.samples.len() < 2 {
218            return 0.0;
219        }
220
221        let mut total = 0.0;
222        for i in 1..self.samples.len() {
223            let prev = &self.samples[i - 1];
224            let curr = &self.samples[i];
225            let dx = curr.position.x - prev.position.x;
226            let dy = curr.position.y - prev.position.y;
227            total += (dx * dx + dy * dy).sqrt();
228        }
229        total
230    }
231
232    /// Get the straight-line distance from first to last sample
233    pub fn direct_distance(&self) -> Option<f32> {
234        let first = self.first_sample()?;
235        let last = self.last_sample()?;
236        let dx = last.position.x - first.position.x;
237        let dy = last.position.y - first.position.y;
238        Some((dx * dx + dy * dy).sqrt())
239    }
240}
241
242/// Result of drag detection analysis
243#[derive(Debug, Clone, Copy, PartialEq)]
244pub struct DetectedDrag {
245    /// Position where drag started
246    pub start_position: LogicalPosition,
247    /// Current/end position of drag
248    pub current_position: LogicalPosition,
249    /// Direct distance dragged (straight line, pixels)
250    pub direct_distance: f32,
251    /// Total distance dragged (following path, pixels)
252    pub total_distance: f32,
253    /// Duration of the drag (milliseconds)
254    pub duration_ms: u64,
255    /// Number of position samples recorded
256    pub sample_count: usize,
257    /// Session ID this drag belongs to
258    pub session_id: u64,
259}
260
261/// Result of long-press detection
262#[derive(Debug, Clone, Copy, PartialEq)]
263pub struct DetectedLongPress {
264    /// Position where long press is happening
265    pub position: LogicalPosition,
266    /// How long the button has been held (milliseconds)
267    pub duration_ms: u64,
268    /// Whether the callback has already been invoked for this long press
269    pub callback_invoked: bool,
270    /// Session ID this long press belongs to
271    pub session_id: u64,
272}
273
274/// Primary direction of a gesture
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276pub enum GestureDirection {
277    Up,
278    Down,
279    Left,
280    Right,
281}
282
283/// Result of pinch gesture detection
284#[derive(Debug, Clone, Copy, PartialEq)]
285pub struct DetectedPinch {
286    /// Scale factor (< 1.0 for pinch in, > 1.0 for pinch out)
287    pub scale: f32,
288    /// Center point of the pinch gesture
289    pub center: LogicalPosition,
290    /// Initial distance between touch points
291    pub initial_distance: f32,
292    /// Current distance between touch points
293    pub current_distance: f32,
294    /// Duration of pinch (milliseconds)
295    pub duration_ms: u64,
296}
297
298/// Result of rotation gesture detection
299#[derive(Debug, Clone, Copy, PartialEq)]
300pub struct DetectedRotation {
301    /// Rotation angle in radians (positive = clockwise)
302    pub angle_radians: f32,
303    /// Center point of rotation
304    pub center: LogicalPosition,
305    /// Duration of rotation (milliseconds)
306    pub duration_ms: u64,
307}
308
309// NOTE: NodeDragState, WindowDragState, FileDropState, DropEffect, DragData, DragEffect
310// are now defined in azul_core::drag and imported above.
311// The old types are kept as type aliases for backwards compatibility.
312
313/// State of an active node drag (after detection)
314/// DEPRECATED: Use `DragContext` with `ActiveDragType::Node` instead.
315pub type NodeDragState = NodeDrag;
316
317/// State of window being dragged (titlebar drag)
318/// DEPRECATED: Use `DragContext` with `ActiveDragType::WindowMove` instead.
319pub type WindowDragState = WindowMoveDrag;
320
321/// State of file(s) being dragged from OS over the window
322/// DEPRECATED: Use `DragContext` with `ActiveDragType::FileDrop` instead.
323pub type FileDropState = FileDropDrag;
324
325/// State of pen/stylus input
326#[derive(Debug, Clone, Copy, PartialEq)]
327#[repr(C)]
328pub struct PenState {
329    /// Current pen position
330    pub position: LogicalPosition,
331    /// Current pressure (0.0 to 1.0)
332    pub pressure: f32,
333    /// Current tilt angles (x_tilt, y_tilt) in degrees
334    pub tilt: crate::callbacks::PenTilt,
335    /// Whether pen is in contact with surface
336    pub in_contact: bool,
337    /// Whether pen is inverted (eraser mode)
338    pub is_eraser: bool,
339    /// Whether barrel button is pressed
340    pub barrel_button_pressed: bool,
341    /// Unique identifier for this pen device
342    pub device_id: u64,
343}
344
345impl_option!(PenState, OptionPenState, [Debug, Clone, Copy, PartialEq]);
346
347impl Default for PenState {
348    fn default() -> Self {
349        Self {
350            position: LogicalPosition::zero(),
351            pressure: 0.0,
352            tilt: crate::callbacks::PenTilt {
353                x_tilt: 0.0,
354                y_tilt: 0.0,
355            },
356            in_contact: false,
357            is_eraser: false,
358            barrel_button_pressed: false,
359            device_id: 0,
360        }
361    }
362}
363
364/// Manager for multi-frame gestures and drag operations
365///
366/// This collects raw input samples and analyzes them to detect gestures.
367/// Designed for testability and clear separation of input collection
368/// vs. detection.
369///
370/// ## Unified Drag System
371///
372/// The manager now uses `DragContext` to unify all drag types:
373/// - `active_drag`: The unified drag context (replaces individual drag states)
374///
375/// For backwards compatibility, the old `node_drag`, `window_drag`, `file_drop`
376/// fields are still accessible but deprecated.
377#[derive(Debug, Clone, PartialEq)]
378pub struct GestureAndDragManager {
379    /// Configuration for gesture detection
380    pub config: GestureDetectionConfig,
381    /// All recorded input sessions (multiple button press sequences)
382    pub input_sessions: Vec<InputSession>,
383    /// **NEW**: Unified drag context for all drag types
384    pub active_drag: Option<DragContext>,
385    /// Current pen/stylus state
386    pub pen_state: Option<PenState>,
387    /// Session IDs where long press callback has been invoked
388    long_press_callbacks_invoked: Vec<u64>,
389    /// Counter for generating unique session IDs
390    next_session_id: u64,
391}
392
393/// Type alias for backwards compatibility
394pub type GestureManager = GestureAndDragManager;
395
396impl Default for GestureAndDragManager {
397    fn default() -> Self {
398        Self::new()
399    }
400}
401
402impl GestureAndDragManager {
403    /// Create a new gesture and drag manager
404    pub fn new() -> Self {
405        Self {
406            config: GestureDetectionConfig::default(),
407            input_sessions: Vec::new(),
408            next_session_id: 1,
409            active_drag: None,
410            pen_state: None,
411            long_press_callbacks_invoked: Vec::new(),
412        }
413    }
414
415    /// Create with custom configuration
416    pub fn with_config(config: GestureDetectionConfig) -> Self {
417        Self {
418            config,
419            ..Self::new()
420        }
421    }
422
423    // Input Recording Methods (called from event loop / system timer)
424
425    /// Start a new input session (mouse button pressed down)
426    ///
427    /// This begins recording samples for gesture detection.
428    /// Call this when receiving mouse button down event.
429    ///
430    /// `window_position` is the current OS window position at the time of mouse-down.
431    /// It is stored so that drag callbacks can compute the new window position.
432    ///
433    /// Returns the session ID for this new session.
434    pub fn start_input_session(
435        &mut self,
436        position: LogicalPosition,
437        timestamp: CoreInstant,
438        button_state: u8,
439        window_position: azul_core::window::WindowPosition,
440        screen_position: LogicalPosition,
441    ) -> u64 {
442        self.start_input_session_with_pen(
443            position,
444            timestamp,
445            button_state,
446            allocate_event_id(),
447            0.5,        // default pressure for mouse
448            (0.0, 0.0), // no tilt for mouse
449            (0.0, 0.0), // no touch radius for mouse
450            window_position,
451            screen_position,
452        )
453    }
454
455    /// Start a new input session with pen/touch data
456    pub fn start_input_session_with_pen(
457        &mut self,
458        position: LogicalPosition,
459        timestamp: CoreInstant,
460        button_state: u8,
461        event_id: u64,
462        pressure: f32,
463        tilt: (f32, f32),
464        touch_radius: (f32, f32),
465        window_position: azul_core::window::WindowPosition,
466        screen_position: LogicalPosition,
467    ) -> u64 {
468        // Clear old ended sessions, but keep the most recent ended session
469        // for double-click detection. detect_double_click() needs two ended
470        // sessions to compare timing and distance.
471        let last_ended_idx = self.input_sessions.iter().rposition(|s| s.ended);
472        let mut idx = 0usize;
473        self.input_sessions.retain(|session| {
474            let keep = !session.ended || Some(idx) == last_ended_idx;
475            idx += 1;
476            keep
477        });
478
479        let session_id = self.next_session_id;
480        self.next_session_id += 1;
481
482        let sample = InputSample {
483            position,
484            screen_position,
485            timestamp,
486            button_state,
487            event_id,
488            pressure,
489            tilt,
490            touch_radius,
491        };
492
493        let session = InputSession::new(session_id, sample, window_position);
494        self.input_sessions.push(session);
495
496        session_id
497    }
498
499    /// Record an input sample to the current session
500    ///
501    /// Call this on every mouse move event while button is pressed,
502    /// and also periodically from a system timer to track long presses.
503    ///
504    /// Returns true if sample was recorded, false if no active session.
505    pub fn record_input_sample(
506        &mut self,
507        position: LogicalPosition,
508        timestamp: CoreInstant,
509        button_state: u8,
510        screen_position: LogicalPosition,
511    ) -> bool {
512        self.record_input_sample_with_pen(
513            position,
514            timestamp,
515            button_state,
516            allocate_event_id(),
517            0.5,        // default pressure for mouse
518            (0.0, 0.0), // no tilt for mouse
519            (0.0, 0.0), // no touch radius for mouse
520            screen_position,
521        )
522    }
523
524    /// Record an input sample with pen/touch data
525    pub fn record_input_sample_with_pen(
526        &mut self,
527        position: LogicalPosition,
528        timestamp: CoreInstant,
529        button_state: u8,
530        event_id: u64,
531        pressure: f32,
532        tilt: (f32, f32),
533        touch_radius: (f32, f32),
534        screen_position: LogicalPosition,
535    ) -> bool {
536        let session = match self.input_sessions.last_mut() {
537            Some(s) => s,
538            None => return false,
539        };
540
541        if session.ended {
542            return false;
543        }
544
545        // Enforce max samples limit
546        if session.samples.len() >= MAX_SAMPLES_PER_SESSION {
547            // Remove oldest samples, keeping the most recent ones
548            let remove_count = session.samples.len() - MAX_SAMPLES_PER_SESSION + 100;
549            session.samples.drain(0..remove_count);
550        }
551
552        session.samples.push(InputSample {
553            position,
554            screen_position,
555            timestamp,
556            button_state,
557            event_id,
558            pressure,
559            tilt,
560            touch_radius,
561        });
562
563        true
564    }
565
566    /// End the current input session (mouse button released)
567    ///
568    /// Call this when receiving mouse button up event.
569    /// The session is kept for analysis but marked as ended.
570    pub fn end_current_session(&mut self) {
571        if let Some(session) = self.input_sessions.last_mut() {
572            session.ended = true;
573        }
574    }
575
576    /// Clear old input sessions that have timed out
577    ///
578    /// Call this periodically (e.g., every frame) to prevent memory leaks.
579    /// Sessions older than `config.sample_cleanup_interval_ms` are removed.
580    pub fn clear_old_sessions(&mut self, current_time: CoreInstant) {
581        self.input_sessions.retain(|session| {
582            if let Some(last_sample) = session.last_sample() {
583                let duration = current_time.duration_since(&last_sample.timestamp);
584                let age_ms = duration_to_millis(duration);
585                age_ms < self.config.sample_cleanup_interval_ms
586            } else {
587                false
588            }
589        });
590
591        // Also clear long press callback tracking for removed sessions
592        let valid_session_ids: Vec<u64> =
593            self.input_sessions.iter().map(|s| s.session_id).collect();
594
595        self.long_press_callbacks_invoked
596            .retain(|id| valid_session_ids.contains(id));
597    }
598
599    /// Clear all input sessions
600    ///
601    /// Call this when you want to reset all gesture detection state.
602    pub fn clear_all_sessions(&mut self) {
603        self.input_sessions.clear();
604        self.long_press_callbacks_invoked.clear();
605    }
606
607    /// Update pen/stylus state
608    ///
609    /// Call this when receiving pen events from the platform.
610    pub fn update_pen_state(
611        &mut self,
612        position: LogicalPosition,
613        pressure: f32,
614        tilt: (f32, f32),
615        in_contact: bool,
616        is_eraser: bool,
617        barrel_button_pressed: bool,
618        device_id: u64,
619    ) {
620        self.pen_state = Some(PenState {
621            position,
622            pressure,
623            tilt: crate::callbacks::PenTilt {
624                x_tilt: tilt.0,
625                y_tilt: tilt.1,
626            },
627            in_contact,
628            is_eraser,
629            barrel_button_pressed,
630            device_id,
631        });
632    }
633
634    /// Clear pen state (when pen leaves proximity)
635    pub fn clear_pen_state(&mut self) {
636        self.pen_state = None;
637    }
638
639    /// Get current pen state (read-only)
640    pub fn get_pen_state(&self) -> Option<&PenState> {
641        self.pen_state.as_ref()
642    }
643
644    // Gesture Detection Methods (query state without mutation)
645
646    /// Detect if current input represents a drag gesture
647    ///
648    /// Returns Some(DetectedDrag) if a drag is detected based on distance threshold.
649    pub fn detect_drag(&self) -> Option<DetectedDrag> {
650        let session = self.get_current_session()?;
651
652        if session.samples.len() < self.config.min_samples_for_gesture {
653            return None;
654        }
655
656        let direct_distance = session.direct_distance()?;
657
658        if direct_distance >= self.config.drag_distance_threshold {
659            let first = session.first_sample()?;
660            let last = session.last_sample()?;
661
662            Some(DetectedDrag {
663                start_position: first.position,
664                current_position: last.position,
665                direct_distance,
666                total_distance: session.total_distance(),
667                duration_ms: session.duration_ms()?,
668                sample_count: session.samples.len(),
669                session_id: session.session_id,
670            })
671        } else {
672            None
673        }
674    }
675
676    /// Detect if current input represents a long press
677    ///
678    /// Returns Some(DetectedLongPress) if button has been held long enough
679    /// without moving much.
680    pub fn detect_long_press(&self) -> Option<DetectedLongPress> {
681        let session = self.get_current_session()?;
682
683        if session.ended {
684            return None; // Can't be long press if button already released
685        }
686
687        let duration_ms = session.duration_ms()?;
688
689        if duration_ms < self.config.long_press_time_threshold_ms {
690            return None;
691        }
692
693        let distance = session.direct_distance()?;
694
695        if distance <= self.config.long_press_distance_threshold {
696            let first = session.first_sample()?;
697            let callback_invoked = self
698                .long_press_callbacks_invoked
699                .contains(&session.session_id);
700
701            Some(DetectedLongPress {
702                position: first.position,
703                duration_ms,
704                callback_invoked,
705                session_id: session.session_id,
706            })
707        } else {
708            None
709        }
710    }
711
712    /// Mark long press callback as invoked for a session
713    ///
714    /// Call this after invoking the long press callback to prevent
715    /// repeated invocations.
716    pub fn mark_long_press_callback_invoked(&mut self, session_id: u64) {
717        if !self.long_press_callbacks_invoked.contains(&session_id) {
718            self.long_press_callbacks_invoked.push(session_id);
719        }
720    }
721
722    /// Detect if last two sessions form a double-click.
723    ///
724    /// Returns true if timing and distance match double-click criteria.
725    pub fn detect_double_click(&self) -> bool {
726        let sessions = &self.input_sessions;
727        if sessions.len() < 2 {
728            return false;
729        }
730
731        let prev_session = &sessions[sessions.len() - 2];
732        let last_session = &sessions[sessions.len() - 1];
733
734        // Both sessions must have ended (button released)
735        if !prev_session.ended || !last_session.ended {
736            return false;
737        }
738
739        let prev_first = prev_session.first_sample();
740        let last_first = last_session.first_sample();
741        let (prev_first, last_first) = match (prev_first, last_first) {
742            (Some(p), Some(l)) => (p, l),
743            _ => return false,
744        };
745
746        let duration = last_first.timestamp.duration_since(&prev_first.timestamp);
747        let time_delta_ms = duration_to_millis(duration);
748        if time_delta_ms > self.config.double_click_time_threshold_ms {
749            return false;
750        }
751
752        let dx = last_first.position.x - prev_first.position.x;
753        let dy = last_first.position.y - prev_first.position.y;
754        let distance = (dx * dx + dy * dy).sqrt();
755
756        distance < self.config.double_click_distance_threshold
757    }
758
759    /// Get the primary direction of current drag.
760    pub fn get_drag_direction(&self) -> Option<GestureDirection> {
761        let session = self.get_current_session()?;
762        let first = session.first_sample()?;
763        let last = session.last_sample()?;
764
765        let dx = last.position.x - first.position.x;
766        let dy = last.position.y - first.position.y;
767
768        let direction = if dx.abs() > dy.abs() {
769            if dx > 0.0 {
770                GestureDirection::Right
771            } else {
772                GestureDirection::Left
773            }
774        } else {
775            if dy > 0.0 {
776                GestureDirection::Down
777            } else {
778                GestureDirection::Up
779            }
780        };
781        Some(direction)
782    }
783
784    /// Get average velocity of current gesture (pixels per second)
785    pub fn get_gesture_velocity(&self) -> Option<f32> {
786        let session = self.get_current_session()?;
787
788        if session.samples.len() < 2 {
789            return None;
790        }
791
792        let total_distance = session.total_distance();
793        let duration_ms = session.duration_ms()?;
794
795        if duration_ms == 0 {
796            return None;
797        }
798
799        let duration_secs = duration_ms as f32 / 1000.0;
800        Some(total_distance / duration_secs)
801    }
802
803    /// Check if current gesture is a swipe (fast directional movement).
804    pub fn is_swipe(&self) -> bool {
805        self.get_gesture_velocity()
806            .map(|v| v >= self.config.swipe_velocity_threshold)
807            .unwrap_or(false)
808    }
809
810    /// Detect swipe with specific direction
811    ///
812    /// Returns Some(dir) if gesture is a fast swipe in a clear direction
813    pub fn detect_swipe_direction(&self) -> Option<GestureDirection> {
814        // Must be a fast swipe first
815        if !self.is_swipe() {
816            return None;
817        }
818
819        // Get direction
820        self.get_drag_direction()
821    }
822
823    /// Detect pinch gesture (two-touch zoom in/out)
824    ///
825    /// Returns Some if two touch points are active and distance is changing
826    /// significantly. Scale < 1.0 = pinch in, scale > 1.0 = pinch out.
827    pub fn detect_pinch(&self) -> Option<DetectedPinch> {
828        // Need at least two active sessions for pinch
829        if self.input_sessions.len() < 2 {
830            return None;
831        }
832
833        // Get last two sessions (most recent touches)
834        let session1 = &self.input_sessions[self.input_sessions.len() - 2];
835        let session2 = &self.input_sessions[self.input_sessions.len() - 1];
836
837        // Both must have samples
838        let first1 = session1.first_sample()?;
839        let first2 = session2.first_sample()?;
840        let last1 = session1.last_sample()?;
841        let last2 = session2.last_sample()?;
842
843        // Calculate initial distance between touches
844        let dx_initial = first2.position.x - first1.position.x;
845        let dy_initial = first2.position.y - first1.position.y;
846        let initial_distance = (dx_initial * dx_initial + dy_initial * dy_initial).sqrt();
847
848        // Calculate current distance
849        let dx_current = last2.position.x - last1.position.x;
850        let dy_current = last2.position.y - last1.position.y;
851        let current_distance = (dx_current * dx_current + dy_current * dy_current).sqrt();
852
853        // Avoid division by zero
854        if initial_distance < 1.0 {
855            return None;
856        }
857
858        // Calculate scale factor
859        let scale = current_distance / initial_distance;
860
861        // Check if scale change is significant (threshold from config)
862        let scale_threshold = 1.0 + self.config.pinch_scale_threshold;
863        if scale > 1.0 / scale_threshold && scale < scale_threshold {
864            return None; // Change too small
865        }
866
867        // Calculate center point
868        let center = LogicalPosition {
869            x: (last1.position.x + last2.position.x) / 2.0,
870            y: (last1.position.y + last2.position.y) / 2.0,
871        };
872
873        // Calculate duration
874        let duration = last1.timestamp.duration_since(&first1.timestamp);
875        let duration_ms = duration_to_millis(duration);
876
877        Some(DetectedPinch {
878            scale,
879            center,
880            initial_distance,
881            current_distance,
882            duration_ms,
883        })
884    }
885
886    /// Detect rotation gesture (two-touch rotate)
887    ///
888    /// Returns Some if two touch points are rotating around center.
889    /// Positive angle = clockwise, negative = counterclockwise.
890    pub fn detect_rotation(&self) -> Option<DetectedRotation> {
891        // Need at least two active sessions
892        if self.input_sessions.len() < 2 {
893            return None;
894        }
895
896        // Get last two sessions
897        let session1 = &self.input_sessions[self.input_sessions.len() - 2];
898        let session2 = &self.input_sessions[self.input_sessions.len() - 1];
899
900        // Both must have samples
901        let first1 = session1.first_sample()?;
902        let first2 = session2.first_sample()?;
903        let last1 = session1.last_sample()?;
904        let last2 = session2.last_sample()?;
905
906        // Calculate center (average of both touches)
907        let center = LogicalPosition {
908            x: (last1.position.x + last2.position.x) / 2.0,
909            y: (last1.position.y + last2.position.y) / 2.0,
910        };
911
912        // Calculate initial angle between touches
913        let dx_initial = first2.position.x - first1.position.x;
914        let dy_initial = first2.position.y - first1.position.y;
915        let initial_angle = dy_initial.atan2(dx_initial);
916
917        // Calculate current angle
918        let dx_current = last2.position.x - last1.position.x;
919        let dy_current = last2.position.y - last1.position.y;
920        let current_angle = dy_current.atan2(dx_current);
921
922        // Calculate angle difference (normalized to -π to π)
923        let mut angle_diff = current_angle - initial_angle;
924
925        // Normalize angle to -π to π range
926        const PI: f32 = core::f32::consts::PI;
927        while angle_diff > PI {
928            angle_diff -= 2.0 * PI;
929        }
930        while angle_diff < -PI {
931            angle_diff += 2.0 * PI;
932        }
933
934        // Check if rotation is significant (threshold from config)
935        if angle_diff.abs() < self.config.rotation_angle_threshold {
936            return None;
937        }
938
939        // Calculate duration
940        let duration = last1.timestamp.duration_since(&first1.timestamp);
941        let duration_ms = duration_to_millis(duration);
942
943        Some(DetectedRotation {
944            angle_radians: angle_diff,
945            center,
946            duration_ms,
947        })
948    }
949
950    /// Get the current active input session (if any)
951    pub fn get_current_session(&self) -> Option<&InputSession> {
952        self.input_sessions.last()
953    }
954
955    /// Get current mouse position from latest sample
956    pub fn get_current_mouse_position(&self) -> Option<LogicalPosition> {
957        self.get_current_session()
958            .and_then(|s| s.last_sample())
959            .map(|sample| sample.position)
960    }
961
962    /// Get the drag delta (current mouse position minus mouse-down position)
963    /// from the current input session.
964    ///
965    /// Returns `None` if there is no active session or not enough samples.
966    pub fn get_drag_delta(&self) -> Option<(f32, f32)> {
967        let session = self.get_current_session()?;
968        let first = session.first_sample()?;
969        let last = session.last_sample()?;
970        Some((
971            last.position.x - first.position.x,
972            last.position.y - first.position.y,
973        ))
974    }
975
976    /// Get the drag delta in **screen-absolute** coordinates.
977    ///
978    /// Unlike `get_drag_delta()` which uses window-local coordinates (and therefore
979    /// oscillates during window drags due to the window moving under the cursor),
980    /// this method uses screen-absolute positions that are stable regardless of
981    /// window movement.
982    ///
983    /// **Use this for window dragging (titlebar drag).**
984    /// Use `get_drag_delta()` for in-window operations (node drag-and-drop, etc.).
985    ///
986    /// Returns `None` if there is no active session or not enough samples.
987    pub fn get_drag_delta_screen(&self) -> Option<(f32, f32)> {
988        let session = self.get_current_session()?;
989        let first = session.first_sample()?;
990        let last = session.last_sample()?;
991        Some((
992            last.screen_position.x - first.screen_position.x,
993            last.screen_position.y - first.screen_position.y,
994        ))
995    }
996
997    /// Get the **incremental** (frame-to-frame) drag delta in screen coordinates.
998    ///
999    /// Returns `(dx, dy)` where `dx = last_screen.x - previous_screen.x` and
1000    /// `dy = last_screen.y - previous_screen.y`.
1001    ///
1002    /// Unlike `get_drag_delta_screen()` which returns the *total* delta since drag
1003    /// start, this returns only the delta since the previous sample. This is used
1004    /// by `titlebar_drag` to apply position changes incrementally:
1005    ///
1006    /// ```text
1007    /// new_pos = current_window_pos + incremental_delta
1008    /// ```
1009    ///
1010    /// This approach is more robust than `initial_pos + total_delta` because it
1011    /// automatically handles external window position changes (DPI change, OS
1012    /// clamping, compositor resize) that would make `initial_pos` stale.
1013    ///
1014    /// Returns `None` if there is no active session or fewer than 2 samples.
1015    pub fn get_drag_delta_screen_incremental(&self) -> Option<(f32, f32)> {
1016        let session = self.get_current_session()?;
1017        let len = session.samples.len();
1018        if len < 2 {
1019            return None;
1020        }
1021        let prev = &session.samples[len - 2];
1022        let last = &session.samples[len - 1];
1023        Some((
1024            last.screen_position.x - prev.screen_position.x,
1025            last.screen_position.y - prev.screen_position.y,
1026        ))
1027    }
1028
1029    /// Get the window position that was stored when the current input session
1030    /// started (i.e. on mouse-down).  Titlebar drag callbacks use this
1031    /// together with `get_drag_delta_screen()` to compute the new window position.
1032    pub fn get_window_position_at_session_start(&self) -> Option<azul_core::window::WindowPosition> {
1033        let session = self.get_current_session()?;
1034        Some(session.window_position_at_start)
1035    }
1036
1037    // ========================================================================
1038    // UNIFIED DRAG CONTEXT API (NEW)
1039    // ========================================================================
1040
1041    /// Get the active drag context (if any)
1042    pub fn get_drag_context(&self) -> Option<&DragContext> {
1043        self.active_drag.as_ref()
1044    }
1045
1046    /// Get the active drag context mutably (if any)
1047    pub fn get_drag_context_mut(&mut self) -> Option<&mut DragContext> {
1048        self.active_drag.as_mut()
1049    }
1050
1051    /// Activate a text selection drag
1052    pub fn activate_text_selection_drag(
1053        &mut self,
1054        dom_id: DomId,
1055        anchor_ifc_node: NodeId,
1056        start_mouse_position: LogicalPosition,
1057    ) {
1058        let session_id = self.current_session_id().unwrap_or(0);
1059        self.active_drag = Some(DragContext::text_selection(
1060            dom_id,
1061            anchor_ifc_node,
1062            start_mouse_position,
1063            session_id,
1064        ));
1065    }
1066
1067    /// Activate a scrollbar thumb drag
1068    pub fn activate_scrollbar_drag(
1069        &mut self,
1070        scroll_container_node: NodeId,
1071        axis: ScrollbarAxis,
1072        start_mouse_position: LogicalPosition,
1073        start_scroll_offset: f32,
1074        track_length_px: f32,
1075        content_length_px: f32,
1076        viewport_length_px: f32,
1077    ) {
1078        let session_id = self.current_session_id().unwrap_or(0);
1079        self.active_drag = Some(DragContext::scrollbar_thumb(
1080            scroll_container_node,
1081            axis,
1082            start_mouse_position,
1083            start_scroll_offset,
1084            track_length_px,
1085            content_length_px,
1086            viewport_length_px,
1087            session_id,
1088        ));
1089    }
1090
1091    /// Activate a node drag-and-drop
1092    pub fn activate_node_drag(
1093        &mut self,
1094        dom_id: DomId,
1095        node_id: NodeId,
1096        drag_data: DragData,
1097        _start_hit_test: Option<HitTest>,
1098    ) {
1099        if let Some(detected) = self.detect_drag() {
1100            self.active_drag = Some(DragContext::node_drag(
1101                dom_id,
1102                node_id,
1103                detected.start_position,
1104                drag_data,
1105                detected.session_id,
1106            ));
1107        }
1108    }
1109
1110    /// Activate a window move drag (titlebar)
1111    pub fn activate_window_drag(
1112        &mut self,
1113        initial_window_position: WindowPosition,
1114        _start_hit_test: Option<HitTest>,
1115    ) {
1116        if let Some(detected) = self.detect_drag() {
1117            self.active_drag = Some(DragContext::window_move(
1118                detected.start_position,
1119                initial_window_position,
1120                detected.session_id,
1121            ));
1122        }
1123    }
1124
1125    /// Start file drop from OS
1126    pub fn start_file_drop(&mut self, files: Vec<AzString>, position: LogicalPosition) {
1127        let session_id = self.current_session_id().unwrap_or(0);
1128        self.active_drag = Some(DragContext::file_drop(files, position, session_id));
1129    }
1130
1131    /// Update positions for active drag (call on mouse move)
1132    pub fn update_active_drag_positions(&mut self, position: LogicalPosition) {
1133        if let Some(ref mut drag) = self.active_drag {
1134            drag.update_position(position);
1135        }
1136    }
1137
1138    /// Update drop target for node or file drag
1139    pub fn update_drop_target(&mut self, target: Option<azul_core::dom::DomNodeId>) {
1140        if let Some(ref mut drag) = self.active_drag {
1141            match &mut drag.drag_type {
1142                ActiveDragType::Node(ref mut node_drag) => {
1143                    node_drag.current_drop_target = target.into();
1144                }
1145                ActiveDragType::FileDrop(ref mut file_drop) => {
1146                    file_drop.drop_target = target.into();
1147                }
1148                _ => {}
1149            }
1150        }
1151    }
1152
1153    /// Update auto-scroll direction for text selection drag
1154    pub fn update_auto_scroll_direction(&mut self, direction: AutoScrollDirection) {
1155        if let Some(ref mut drag) = self.active_drag {
1156            if let Some(text_drag) = drag.as_text_selection_mut() {
1157                text_drag.auto_scroll_direction = direction;
1158            }
1159        }
1160    }
1161
1162    /// End the current drag and return the context
1163    pub fn end_drag(&mut self) -> Option<DragContext> {
1164        self.active_drag.take()
1165    }
1166
1167    /// Cancel the current drag
1168    pub fn cancel_drag(&mut self) {
1169        if let Some(ref mut drag) = self.active_drag {
1170            drag.cancelled = true;
1171        }
1172        self.active_drag = None;
1173    }
1174
1175    // ========================================================================
1176    // QUERY METHODS
1177    // ========================================================================
1178
1179    /// Check if any drag operation is in progress
1180    pub fn is_dragging(&self) -> bool {
1181        self.active_drag.is_some()
1182    }
1183
1184    /// Check if a text selection drag is active
1185    pub fn is_text_selection_dragging(&self) -> bool {
1186        self.active_drag.as_ref().is_some_and(|d| d.is_text_selection())
1187    }
1188
1189    /// Check if a scrollbar thumb drag is active
1190    pub fn is_scrollbar_dragging(&self) -> bool {
1191        self.active_drag.as_ref().is_some_and(|d| d.is_scrollbar_thumb())
1192    }
1193
1194    /// Check if a node drag is active
1195    pub fn is_node_dragging_any(&self) -> bool {
1196        self.active_drag.as_ref().is_some_and(|d| d.is_node_drag())
1197    }
1198
1199    /// Check if a node drag is active (alias for event determination)
1200    pub fn is_node_drag_active(&self) -> bool {
1201        self.is_node_dragging_any()
1202    }
1203
1204    /// Check if a specific node is being dragged
1205    pub fn is_node_dragging(&self, dom_id: DomId, node_id: NodeId) -> bool {
1206        self.active_drag.as_ref().is_some_and(|d| {
1207            if let Some(node_drag) = d.as_node_drag() {
1208                node_drag.dom_id == dom_id && node_drag.node_id == node_id
1209            } else {
1210                false
1211            }
1212        })
1213    }
1214
1215    /// Check if window drag is active
1216    pub fn is_window_dragging(&self) -> bool {
1217        self.active_drag.as_ref().is_some_and(|d| d.is_window_move())
1218    }
1219
1220    /// Check if file drop is active
1221    pub fn is_file_dropping(&self) -> bool {
1222        self.active_drag.as_ref().is_some_and(|d| d.is_file_drop())
1223    }
1224
1225    /// Get number of active input sessions
1226    pub fn session_count(&self) -> usize {
1227        self.input_sessions.len()
1228    }
1229
1230    /// Get current session ID (if any)
1231    pub fn current_session_id(&self) -> Option<u64> {
1232        self.get_current_session().map(|s| s.session_id)
1233    }
1234
1235    // ========================================================================
1236    // BACKWARDS COMPATIBILITY (DEPRECATED)
1237    // ========================================================================
1238
1239    /// Get current node drag state (if any)
1240    /// DEPRECATED: Use `get_drag_context()` and check for `ActiveDragType::Node`
1241    pub fn get_node_drag(&self) -> Option<&NodeDrag> {
1242        self.active_drag.as_ref().and_then(|d| d.as_node_drag())
1243    }
1244
1245    /// Get current window drag state (if any)
1246    /// DEPRECATED: Use `get_drag_context()` and check for `ActiveDragType::WindowMove`
1247    pub fn get_window_drag(&self) -> Option<&WindowMoveDrag> {
1248        self.active_drag.as_ref().and_then(|d| d.as_window_move())
1249    }
1250
1251    /// Get current file drop state (if any)
1252    /// DEPRECATED: Use `get_drag_context()` and check for `ActiveDragType::FileDrop`
1253    pub fn get_file_drop(&self) -> Option<&FileDropDrag> {
1254        self.active_drag.as_ref().and_then(|d| d.as_file_drop())
1255    }
1256
1257    /// End node drag (returns None - use end_drag() instead)
1258    /// DEPRECATED: Use `end_drag()` instead
1259    pub fn end_node_drag(&mut self) -> Option<DragContext> {
1260        if self.active_drag.as_ref().is_some_and(|d| d.is_node_drag()) {
1261            self.end_drag()
1262        } else {
1263            None
1264        }
1265    }
1266
1267    /// End window drag (returns None - use end_drag() instead)
1268    /// DEPRECATED: Use `end_drag()` instead
1269    pub fn end_window_drag(&mut self) -> Option<DragContext> {
1270        if self.active_drag.as_ref().is_some_and(|d| d.is_window_move()) {
1271            self.end_drag()
1272        } else {
1273            None
1274        }
1275    }
1276
1277    /// End file drop (returns None - use end_drag() instead)
1278    /// DEPRECATED: Use `end_drag()` instead
1279    pub fn end_file_drop(&mut self) -> Option<DragContext> {
1280        if self.active_drag.as_ref().is_some_and(|d| d.is_file_drop()) {
1281            self.end_drag()
1282        } else {
1283            None
1284        }
1285    }
1286
1287    /// Cancel file drop
1288    /// DEPRECATED: Use `cancel_drag()` instead
1289    pub fn cancel_file_drop(&mut self) {
1290        if self.active_drag.as_ref().is_some_and(|d| d.is_file_drop()) {
1291            self.cancel_drag();
1292        }
1293    }
1294
1295    // ========================================================================
1296    // WINDOW DRAG HELPER METHODS
1297    // ========================================================================
1298
1299    /// Calculate window position delta from current drag state
1300    ///
1301    /// Returns (delta_x, delta_y) to apply to window position.
1302    /// Returns None if no window drag is active or drag hasn't moved.
1303    pub fn get_window_drag_delta(&self) -> Option<(i32, i32)> {
1304        let drag = self.active_drag.as_ref()?.as_window_move()?;
1305
1306        let delta_x = drag.current_position.x - drag.start_position.x;
1307        let delta_y = drag.current_position.y - drag.start_position.y;
1308
1309        match drag.initial_window_position {
1310            WindowPosition::Initialized(_initial_pos) => Some((delta_x as i32, delta_y as i32)),
1311            _ => None,
1312        }
1313    }
1314
1315    /// Get the new window position based on current drag
1316    ///
1317    /// Returns the absolute window position to set.
1318    pub fn get_window_position_from_drag(&self) -> Option<WindowPosition> {
1319        let drag = self.active_drag.as_ref()?.as_window_move()?;
1320
1321        let delta_x = drag.current_position.x - drag.start_position.x;
1322        let delta_y = drag.current_position.y - drag.start_position.y;
1323
1324        match drag.initial_window_position {
1325            WindowPosition::Initialized(initial_pos) => {
1326                Some(WindowPosition::Initialized(PhysicalPositionI32::new(
1327                    initial_pos.x + delta_x as i32,
1328                    initial_pos.y + delta_y as i32,
1329                )))
1330            }
1331            _ => None,
1332        }
1333    }
1334
1335    /// Calculate the new scroll offset for scrollbar thumb drag
1336    pub fn get_scrollbar_scroll_offset(&self) -> Option<f32> {
1337        self.active_drag.as_ref()?.calculate_scrollbar_scroll_offset()
1338    }
1339
1340    /// Remap NodeIds in active drag context after DOM reconciliation.
1341    ///
1342    /// When the DOM is regenerated during an active drag, NodeIds can change.
1343    /// If a critical NodeId was removed, the drag is cancelled.
1344    pub fn remap_node_ids(
1345        &mut self,
1346        dom_id: azul_core::dom::DomId,
1347        node_id_map: &std::collections::BTreeMap<azul_core::id::NodeId, azul_core::id::NodeId>,
1348    ) {
1349        if let Some(ref mut drag) = self.active_drag {
1350            if !drag.remap_node_ids(dom_id, node_id_map) {
1351                // Critical node removed — cancel the drag
1352                drag.cancelled = true;
1353                self.active_drag = None;
1354            }
1355        }
1356    }
1357}