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            // WARNING: assumes 1 tick = 1 ms. This is correct for platforms
76            // that use a millisecond tick counter, but will silently produce
77            // wrong timing on platforms with a different tick resolution.
78            tick_diff.tick_diff
79        }
80    }
81}
82
83/// Maximum number of input samples to keep in memory
84///
85/// This prevents unbounded memory growth during long drags.
86/// Older samples beyond this limit are automatically discarded.
87pub const MAX_SAMPLES_PER_SESSION: usize = 1000;
88
89/// Default timeout for clearing old gesture samples (milliseconds)
90///
91/// Samples older than this are automatically removed to prevent
92/// memory leaks and stale gesture detection.
93pub const DEFAULT_SAMPLE_TIMEOUT_MS: u64 = 2000;
94
95/// Number of samples to drain at once when the session exceeds `MAX_SAMPLES_PER_SESSION`.
96///
97/// Batch draining avoids per-sample overhead on every new sample.
98const DRAIN_BATCH_SIZE: usize = 100;
99
100/// Configuration for gesture detection thresholds
101#[derive(Debug, Clone, Copy, PartialEq)]
102pub struct GestureDetectionConfig {
103    /// Minimum distance (pixels) to consider movement a drag, not a click
104    pub drag_distance_threshold: f32,
105    /// Maximum time between clicks for double-click detection (milliseconds)
106    pub double_click_time_threshold_ms: u64,
107    /// Maximum distance between clicks for double-click detection (pixels)
108    pub double_click_distance_threshold: f32,
109    /// Minimum time to hold button for long-press detection (milliseconds)
110    pub long_press_time_threshold_ms: u64,
111    /// Maximum distance to move while holding for long-press (pixels)
112    pub long_press_distance_threshold: f32,
113    /// Minimum samples needed to detect a gesture
114    pub min_samples_for_gesture: usize,
115    /// Minimum velocity for swipe detection (pixels per second)
116    pub swipe_velocity_threshold: f32,
117    /// Minimum scale change for pinch detection (e.g., 0.1 = 10% change)
118    pub pinch_scale_threshold: f32,
119    /// Minimum rotation angle for rotation detection (radians)
120    pub rotation_angle_threshold: f32,
121    /// How often to clear old samples (milliseconds)
122    pub sample_cleanup_interval_ms: u64,
123}
124
125impl Default for GestureDetectionConfig {
126    fn default() -> Self {
127        Self {
128            drag_distance_threshold: 5.0,
129            double_click_time_threshold_ms: 500,
130            double_click_distance_threshold: 5.0,
131            long_press_time_threshold_ms: 500,
132            long_press_distance_threshold: 10.0,
133            min_samples_for_gesture: 2,
134            swipe_velocity_threshold: 500.0, // 500 px/s
135            pinch_scale_threshold: 0.1,      // 10% scale change
136            rotation_angle_threshold: 0.1,   // ~5.7 degrees in radians
137            sample_cleanup_interval_ms: DEFAULT_SAMPLE_TIMEOUT_MS,
138        }
139    }
140}
141
142/// Single input sample with position and timestamp
143#[derive(Debug, Clone, PartialEq)]
144pub struct InputSample {
145    /// Position in logical coordinates (window-local, Y=0 at top of window)
146    pub position: LogicalPosition,
147    /// Position in virtual screen coordinates (Y=0 at top of primary monitor).
148    ///
149    /// Computed as `window_position + position` at the time the sample is recorded.
150    /// This is stable during window drags because `window_pos + cursor_local`
151    /// always equals the true screen position, even when the window moves.
152    ///
153    /// All coordinates are in logical pixels (HiDPI-independent).
154    /// On Wayland, this is an estimate (compositor does not expose global position).
155    pub screen_position: LogicalPosition,
156    /// Timestamp when this sample was recorded (from ExternalSystemCallbacks)
157    pub timestamp: CoreInstant,
158    /// Mouse button state (bitfield: 0x01 = left, 0x02 = right, 0x04 = middle)
159    pub button_state: u8,
160    /// Unique, monotonic event ID for ordering (atomic counter)
161    pub event_id: u64,
162    /// Pen/stylus pressure (0.0 to 1.0, 0.5 = default for mouse)
163    pub pressure: f32,
164    /// Pen/stylus tilt angles in degrees (x_tilt, y_tilt)
165    /// Range: typically -90.0 to 90.0, (0.0, 0.0) = perpendicular
166    pub tilt: (f32, f32),
167    /// Touch contact radius in logical pixels (width, height)
168    /// For mouse input, this is (0.0, 0.0)
169    pub touch_radius: (f32, f32),
170}
171
172impl_option!(
173    InputSample,
174    OptionInputSample,
175    copy = false,
176    [Debug, Clone, PartialEq]
177);
178
179/// A sequence of input samples forming one button press session
180#[derive(Debug, Clone, PartialEq)]
181pub struct InputSession {
182    /// All recorded samples for this session
183    pub samples: Vec<InputSample>,
184    /// Whether this session has ended (button released)
185    pub ended: bool,
186    /// Session ID for tracking (incremental counter)
187    pub session_id: u64,
188    /// Window position at the time this session started (mouse-down).
189    /// Used by titlebar drag callbacks to compute new window position.
190    pub window_position_at_start: azul_core::window::WindowPosition,
191}
192
193impl InputSession {
194    /// Create a new input session
195    fn new(session_id: u64, first_sample: InputSample, window_position: azul_core::window::WindowPosition) -> Self {
196        Self {
197            samples: vec![first_sample],
198            ended: false,
199            session_id,
200            window_position_at_start: window_position,
201        }
202    }
203
204    /// Get the first sample in this session
205    pub fn first_sample(&self) -> Option<&InputSample> {
206        self.samples.first()
207    }
208
209    /// Get the last sample in this session
210    pub fn last_sample(&self) -> Option<&InputSample> {
211        self.samples.last()
212    }
213
214    /// Get the duration of this session (first to last sample)
215    pub fn duration_ms(&self) -> Option<u64> {
216        let first = self.first_sample()?;
217        let last = self.last_sample()?;
218        let duration = last.timestamp.duration_since(&first.timestamp);
219        Some(duration_to_millis(duration))
220    }
221
222    /// Get the total distance traveled in this session
223    pub fn total_distance(&self) -> f32 {
224        if self.samples.len() < 2 {
225            return 0.0;
226        }
227
228        let mut total = 0.0;
229        for i in 1..self.samples.len() {
230            let prev = &self.samples[i - 1];
231            let curr = &self.samples[i];
232            let dx = curr.position.x - prev.position.x;
233            let dy = curr.position.y - prev.position.y;
234            total += (dx * dx + dy * dy).sqrt();
235        }
236        total
237    }
238
239    /// Get the straight-line distance from first to last sample
240    pub fn direct_distance(&self) -> Option<f32> {
241        let first = self.first_sample()?;
242        let last = self.last_sample()?;
243        let dx = last.position.x - first.position.x;
244        let dy = last.position.y - first.position.y;
245        Some((dx * dx + dy * dy).sqrt())
246    }
247}
248
249/// Result of drag detection analysis
250#[derive(Debug, Clone, Copy, PartialEq)]
251pub struct DetectedDrag {
252    /// Position where drag started
253    pub start_position: LogicalPosition,
254    /// Current/end position of drag
255    pub current_position: LogicalPosition,
256    /// Direct distance dragged (straight line, pixels)
257    pub direct_distance: f32,
258    /// Total distance dragged (following path, pixels)
259    pub total_distance: f32,
260    /// Duration of the drag (milliseconds)
261    pub duration_ms: u64,
262    /// Number of position samples recorded
263    pub sample_count: usize,
264    /// Session ID this drag belongs to
265    pub session_id: u64,
266}
267
268/// Result of long-press detection
269#[derive(Debug, Clone, Copy, PartialEq)]
270#[repr(C)]
271pub struct DetectedLongPress {
272    /// Position where long press is happening
273    pub position: LogicalPosition,
274    /// How long the button has been held (milliseconds)
275    pub duration_ms: u64,
276    /// Whether the callback has already been invoked for this long press
277    pub callback_invoked: bool,
278    /// Session ID this long press belongs to
279    pub session_id: u64,
280}
281
282/// Primary direction of a gesture
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
284#[repr(C)]
285pub enum GestureDirection {
286    Up,
287    Down,
288    Left,
289    Right,
290}
291
292impl_option!(
293    GestureDirection,
294    OptionGestureDirection,
295    [Debug, Clone, Copy, PartialEq, Eq]
296);
297impl_option!(
298    DetectedPinch,
299    OptionDetectedPinch,
300    [Debug, Clone, Copy, PartialEq]
301);
302impl_option!(
303    DetectedRotation,
304    OptionDetectedRotation,
305    [Debug, Clone, Copy, PartialEq]
306);
307impl_option!(
308    DetectedLongPress,
309    OptionDetectedLongPress,
310    [Debug, Clone, Copy, PartialEq]
311);
312
313/// Result of pinch gesture detection
314#[derive(Debug, Clone, Copy, PartialEq)]
315#[repr(C)]
316pub struct DetectedPinch {
317    /// Scale factor (< 1.0 for pinch in, > 1.0 for pinch out)
318    pub scale: f32,
319    /// Center point of the pinch gesture
320    pub center: LogicalPosition,
321    /// Initial distance between touch points
322    pub initial_distance: f32,
323    /// Current distance between touch points
324    pub current_distance: f32,
325    /// Duration of pinch (milliseconds)
326    pub duration_ms: u64,
327}
328
329/// Result of rotation gesture detection
330#[derive(Debug, Clone, Copy, PartialEq)]
331#[repr(C)]
332pub struct DetectedRotation {
333    /// Rotation angle in radians (positive = clockwise)
334    pub angle_radians: f32,
335    /// Center point of rotation
336    pub center: LogicalPosition,
337    /// Duration of rotation (milliseconds)
338    pub duration_ms: u64,
339}
340
341// NOTE: NodeDragState, WindowDragState, FileDropState, DropEffect, DragData, DragEffect
342// are now defined in azul_core::drag and imported above.
343// The old types are kept as type aliases for backwards compatibility.
344
345/// State of an active node drag (after detection)
346/// DEPRECATED: Use `DragContext` with `ActiveDragType::Node` instead.
347pub type NodeDragState = NodeDrag;
348
349/// State of window being dragged (titlebar drag)
350/// DEPRECATED: Use `DragContext` with `ActiveDragType::WindowMove` instead.
351pub type WindowDragState = WindowMoveDrag;
352
353/// State of file(s) being dragged from OS over the window
354/// DEPRECATED: Use `DragContext` with `ActiveDragType::FileDrop` instead.
355pub type FileDropState = FileDropDrag;
356
357/// State of pen/stylus input
358#[derive(Debug, Clone, Copy, PartialEq)]
359#[repr(C)]
360pub struct PenState {
361    /// Current pen position
362    pub position: LogicalPosition,
363    /// Current pressure (0.0 to 1.0)
364    pub pressure: f32,
365    /// Current tilt angles (x_tilt, y_tilt) in degrees
366    pub tilt: crate::callbacks::PenTilt,
367    /// Whether pen is in contact with surface
368    pub in_contact: bool,
369    /// Whether pen is inverted (eraser mode)
370    pub is_eraser: bool,
371    /// Whether barrel button is pressed
372    pub barrel_button_pressed: bool,
373    /// Unique identifier for this pen device
374    pub device_id: u64,
375    /// Tangential / cylinder pressure (0.0 to 1.0). Wacom Air Brush wheel,
376    /// Surface Slim Pen 2 secondary axis. `0.0` means "not reported".
377    /// Maps to W3C `PointerEvent.tangentialPressure`.
378    pub tangential_pressure: f32,
379    /// Barrel roll angle in radians (–π to π). Wacom Art Pen rotation,
380    /// Surface Pen barrel-roll axis. `0.0` means "not reported" (devices
381    /// that do report it sweep through the full range as the user rolls
382    /// the pen — the resting state isn't necessarily zero, so callers
383    /// should compare deltas, not absolute values).
384    /// Maps to W3C `PointerEvent.twist` (in radians, not degrees).
385    pub barrel_roll_rad: f32,
386    /// Per-tool identity for hand-held pens that report it (Wintab GUID,
387    /// Apple Pencil session id, S-Pen serial). `0` means "not reported".
388    /// Distinct from `device_id` so callers can both identify the
389    /// hardware (device_id) *and* which tip / lead / button cluster is
390    /// in use (tool_id).
391    pub tool_id: u32,
392}
393
394impl_option!(PenState, OptionPenState, [Debug, Clone, Copy, PartialEq]);
395
396impl Default for PenState {
397    fn default() -> Self {
398        Self {
399            position: LogicalPosition::zero(),
400            pressure: 0.0,
401            tilt: crate::callbacks::PenTilt {
402                x_tilt: 0.0,
403                y_tilt: 0.0,
404            },
405            in_contact: false,
406            is_eraser: false,
407            barrel_button_pressed: false,
408            device_id: 0,
409            tangential_pressure: 0.0,
410            barrel_roll_rad: 0.0,
411            tool_id: 0,
412        }
413    }
414}
415
416/// State of a Wacom-style tablet **pad** — the tablet body's own hardware
417/// controls, distinct from the pen ([`PenState`] already covers eraser /
418/// barrel button / barrel roll / tilt / pressure). Populated by the platform
419/// backend (`dll/src/desktop/extra/wacom_pad/`: Wintab on Windows,
420/// libwacom+libinput on Linux, the driver's `NSEvent` tablet events on macOS).
421#[derive(Debug, Clone, Copy, PartialEq)]
422#[repr(C)]
423pub struct WacomPadState {
424    /// ExpressKey bitset — bit `n` set ⇔ hardware button `n` is held (up to
425    /// 32). Read via [`WacomPadState::express_key`].
426    pub express_keys: u32,
427    /// Touch-ring / touch-strip absolute position, `0.0`–`1.0`. Only
428    /// meaningful while [`WacomPadState::touch_ring_active`] is `true`.
429    pub touch_ring: f32,
430    /// Whether a finger is currently on the touch-ring / touch-strip.
431    pub touch_ring_active: bool,
432    /// Tablet device id (to distinguish pads on multi-tablet setups).
433    pub device_id: u64,
434}
435
436impl_option!(
437    WacomPadState,
438    OptionWacomPadState,
439    [Debug, Clone, Copy, PartialEq]
440);
441
442impl Default for WacomPadState {
443    fn default() -> Self {
444        Self {
445            express_keys: 0,
446            touch_ring: 0.0,
447            touch_ring_active: false,
448            device_id: 0,
449        }
450    }
451}
452
453impl WacomPadState {
454    /// Whether ExpressKey `index` (0-based, < 32) is currently held.
455    pub fn express_key(&self, index: u32) -> bool {
456        index < 32 && (self.express_keys & (1u32 << index)) != 0
457    }
458}
459
460/// Manager for multi-frame gestures and drag operations
461///
462/// This collects raw input samples and analyzes them to detect gestures.
463/// Designed for testability and clear separation of input collection
464/// vs. detection.
465///
466/// ## Unified Drag System
467///
468/// The manager now uses `DragContext` to unify all drag types:
469/// - `active_drag`: The unified drag context (replaces individual drag states)
470///
471/// For backwards compatibility, the old `node_drag`, `window_drag`, `file_drop`
472/// fields are still accessible but deprecated.
473#[derive(Debug, Clone, PartialEq)]
474pub struct GestureAndDragManager {
475    /// Configuration for gesture detection
476    pub config: GestureDetectionConfig,
477    /// All recorded input sessions (multiple button press sequences)
478    pub input_sessions: Vec<InputSession>,
479    /// **NEW**: Unified drag context for all drag types
480    pub active_drag: Option<DragContext>,
481    /// Current pen/stylus state
482    pub pen_state: Option<PenState>,
483    /// Pen state as of the previous determine-events pass (for diffing pen events).
484    pub previous_pen_state: Option<PenState>,
485    /// Set when pen state changed; gates one pen-event diff (cleared by the event loop).
486    pub pen_event_pending: bool,
487    /// Latest Wacom tablet-pad state (ExpressKeys + touch-ring), or `None`
488    /// until a pad backend delivers one.
489    pub pad_state: Option<WacomPadState>,
490    /// Session IDs where long press callback has been invoked
491    long_press_callbacks_invoked: Vec<u64>,
492    /// Counter for generating unique session IDs
493    next_session_id: u64,
494    /// Native-platform gesture override slot.
495    ///
496    /// Platforms with first-class gesture recognizers (iOS UIKit,
497    /// Android `GestureDetector` + `ScaleGestureDetector`, macOS
498    /// `NSGestureRecognizer`) inject pre-detected gestures here via
499    /// [`GestureAndDragManager::inject_native_gesture`]. The
500    /// `detect_*` methods consult this slot before running their
501    /// in-process heuristics, so callbacks observe consistent results
502    /// regardless of the detection source.
503    ///
504    /// Cleared automatically at the start of every new input recording
505    /// cycle so a single OS event doesn't keep firing.
506    pub native_gesture: Option<NativeGestureEvent>,
507}
508
509/// Gesture detected by a platform-native recognizer.
510///
511/// Platform backends construct one of these in their gesture-recognizer
512/// callbacks (iOS UIKit, Android `GestureDetector`, macOS
513/// `NSGestureRecognizer`) and hand it to
514/// [`GestureAndDragManager::inject_native_gesture`]. The in-process
515/// `detect_*` methods then return the native result, sidestepping their
516/// fallback heuristics. On platforms with poor native gesture support
517/// (X11 / Wayland touch, headless), backends never inject and the
518/// in-process detector remains authoritative.
519#[derive(Debug, Clone, Copy, PartialEq)]
520#[repr(C, u8)]
521pub enum NativeGestureEvent {
522    /// Single tap / double-click detected natively.
523    DoubleClick,
524    /// Long-press detected natively (iOS `UILongPressGestureRecognizer`,
525    /// Android `GestureDetector.OnGestureListener::onLongPress`).
526    LongPress(DetectedLongPress),
527    /// Swipe detected natively (iOS `UISwipeGestureRecognizer`,
528    /// Android `GestureDetector.OnGestureListener::onFling`).
529    Swipe(GestureDirection),
530    /// Pinch detected natively (iOS `UIPinchGestureRecognizer`,
531    /// Android `ScaleGestureDetector`, macOS magnification gesture).
532    Pinch(DetectedPinch),
533    /// Rotation detected natively (iOS `UIRotationGestureRecognizer`,
534    /// macOS rotation gesture).
535    Rotation(DetectedRotation),
536}
537
538/// Type alias for backwards compatibility
539pub type GestureManager = GestureAndDragManager;
540
541impl Default for GestureAndDragManager {
542    fn default() -> Self {
543        Self::new()
544    }
545}
546
547impl GestureAndDragManager {
548    /// (input_sessions, long_press_callbacks_invoked). Used by
549    /// `AZ_E2E_TEST` to watch for unbounded growth.
550    pub fn debug_counts(&self) -> (usize, usize) {
551        (self.input_sessions.len(), self.long_press_callbacks_invoked.len())
552    }
553
554    /// Create a new gesture and drag manager
555    pub fn new() -> Self {
556        Self {
557            config: GestureDetectionConfig::default(),
558            input_sessions: Vec::new(),
559            next_session_id: 1,
560            active_drag: None,
561            pen_state: None,
562            previous_pen_state: None,
563            pen_event_pending: false,
564            pad_state: None,
565            long_press_callbacks_invoked: Vec::new(),
566            native_gesture: None,
567        }
568    }
569
570    /// Inject a native gesture-recognizer result, overriding the
571    /// in-process detector for the current event frame. Called by the
572    /// iOS / Android / macOS platform backend from their gesture
573    /// recognizer callbacks. The override is read once by the next
574    /// `detect_*` call.
575    pub fn inject_native_gesture(&mut self, gesture: NativeGestureEvent) {
576        self.native_gesture = Some(gesture);
577    }
578
579    /// Clear any pending native-gesture override. Called by the event
580    /// loop after each frame's detections have been consumed so a
581    /// stale OS gesture doesn't keep firing.
582    pub fn clear_native_gesture(&mut self) {
583        self.native_gesture = None;
584    }
585
586    /// Create with custom configuration
587    pub fn with_config(config: GestureDetectionConfig) -> Self {
588        Self {
589            config,
590            ..Self::new()
591        }
592    }
593
594    // Input Recording Methods (called from event loop / system timer)
595
596    /// Start a new input session (mouse button pressed down)
597    ///
598    /// This begins recording samples for gesture detection.
599    /// Call this when receiving mouse button down event.
600    ///
601    /// `window_position` is the current OS window position at the time of mouse-down.
602    /// It is stored so that drag callbacks can compute the new window position.
603    ///
604    /// Returns the session ID for this new session.
605    pub fn start_input_session(
606        &mut self,
607        position: LogicalPosition,
608        timestamp: CoreInstant,
609        button_state: u8,
610        window_position: azul_core::window::WindowPosition,
611        screen_position: LogicalPosition,
612    ) -> u64 {
613        self.start_input_session_with_pen(
614            position,
615            timestamp,
616            button_state,
617            allocate_event_id(),
618            0.5,        // default pressure for mouse
619            (0.0, 0.0), // no tilt for mouse
620            (0.0, 0.0), // no touch radius for mouse
621            window_position,
622            screen_position,
623        )
624    }
625
626    /// Start a new input session with pen/touch data
627    pub fn start_input_session_with_pen(
628        &mut self,
629        position: LogicalPosition,
630        timestamp: CoreInstant,
631        button_state: u8,
632        event_id: u64,
633        pressure: f32,
634        tilt: (f32, f32),
635        touch_radius: (f32, f32),
636        window_position: azul_core::window::WindowPosition,
637        screen_position: LogicalPosition,
638    ) -> u64 {
639        // Clear old ended sessions, but keep the most recent ended session
640        // for double-click detection. detect_double_click() needs two ended
641        // sessions to compare timing and distance.
642        let last_ended_idx = self.input_sessions.iter().rposition(|s| s.ended);
643        let mut idx = 0usize;
644        self.input_sessions.retain(|session| {
645            let keep = !session.ended || Some(idx) == last_ended_idx;
646            idx += 1;
647            keep
648        });
649
650        let session_id = self.next_session_id;
651        self.next_session_id += 1;
652
653        let sample = InputSample {
654            position,
655            screen_position,
656            timestamp,
657            button_state,
658            event_id,
659            pressure,
660            tilt,
661            touch_radius,
662        };
663
664        let session = InputSession::new(session_id, sample, window_position);
665        self.input_sessions.push(session);
666
667        session_id
668    }
669
670    /// Record an input sample to the current session
671    ///
672    /// Call this on every mouse move event while button is pressed,
673    /// and also periodically from a system timer to track long presses.
674    ///
675    /// Returns true if sample was recorded, false if no active session.
676    pub fn record_input_sample(
677        &mut self,
678        position: LogicalPosition,
679        timestamp: CoreInstant,
680        button_state: u8,
681        screen_position: LogicalPosition,
682    ) -> bool {
683        self.record_input_sample_with_pen(
684            position,
685            timestamp,
686            button_state,
687            allocate_event_id(),
688            0.5,        // default pressure for mouse
689            (0.0, 0.0), // no tilt for mouse
690            (0.0, 0.0), // no touch radius for mouse
691            screen_position,
692        )
693    }
694
695    /// Record an input sample with pen/touch data
696    pub fn record_input_sample_with_pen(
697        &mut self,
698        position: LogicalPosition,
699        timestamp: CoreInstant,
700        button_state: u8,
701        event_id: u64,
702        pressure: f32,
703        tilt: (f32, f32),
704        touch_radius: (f32, f32),
705        screen_position: LogicalPosition,
706    ) -> bool {
707        let session = match self.input_sessions.last_mut() {
708            Some(s) => s,
709            None => return false,
710        };
711
712        if session.ended {
713            return false;
714        }
715
716        // Enforce max samples limit
717        if session.samples.len() >= MAX_SAMPLES_PER_SESSION {
718            // Remove oldest samples, keeping the most recent ones
719            let remove_count = session.samples.len() - MAX_SAMPLES_PER_SESSION + DRAIN_BATCH_SIZE;
720            session.samples.drain(0..remove_count);
721        }
722
723        session.samples.push(InputSample {
724            position,
725            screen_position,
726            timestamp,
727            button_state,
728            event_id,
729            pressure,
730            tilt,
731            touch_radius,
732        });
733
734        true
735    }
736
737    /// End the current input session (mouse button released)
738    ///
739    /// Call this when receiving mouse button up event.
740    /// The session is kept for analysis but marked as ended.
741    pub fn end_current_session(&mut self) {
742        if let Some(session) = self.input_sessions.last_mut() {
743            session.ended = true;
744        }
745    }
746
747    /// Clear old input sessions that have timed out
748    ///
749    /// Call this periodically (e.g., every frame) to prevent memory leaks.
750    /// Sessions older than `config.sample_cleanup_interval_ms` are removed.
751    pub fn clear_old_sessions(&mut self, current_time: CoreInstant) {
752        self.input_sessions.retain(|session| {
753            if let Some(last_sample) = session.last_sample() {
754                let duration = current_time.duration_since(&last_sample.timestamp);
755                let age_ms = duration_to_millis(duration);
756                age_ms < self.config.sample_cleanup_interval_ms
757            } else {
758                false
759            }
760        });
761
762        // Also clear long press callback tracking for removed sessions
763        let valid_session_ids: Vec<u64> =
764            self.input_sessions.iter().map(|s| s.session_id).collect();
765
766        self.long_press_callbacks_invoked
767            .retain(|id| valid_session_ids.contains(id));
768    }
769
770    /// Clear all input sessions
771    ///
772    /// Call this when you want to reset all gesture detection state.
773    pub fn clear_all_sessions(&mut self) {
774        self.input_sessions.clear();
775        self.long_press_callbacks_invoked.clear();
776    }
777
778    /// Update pen/stylus state
779    ///
780    /// Call this when receiving pen events from the platform. The
781    /// extended fields (`tangential_pressure`, `barrel_roll_rad`,
782    /// `tool_id`) default to `0` — pass [`update_pen_state_full`] when
783    /// the platform reports them.
784    pub fn update_pen_state(
785        &mut self,
786        position: LogicalPosition,
787        pressure: f32,
788        tilt: (f32, f32),
789        in_contact: bool,
790        is_eraser: bool,
791        barrel_button_pressed: bool,
792        device_id: u64,
793    ) {
794        self.update_pen_state_full(
795            position,
796            pressure,
797            tilt,
798            in_contact,
799            is_eraser,
800            barrel_button_pressed,
801            device_id,
802            0.0,
803            0.0,
804            0,
805        );
806    }
807
808    /// Update pen/stylus state including the extended axes (W3C
809    /// `PointerEvent.tangentialPressure` + `twist`) and per-tool id.
810    pub fn update_pen_state_full(
811        &mut self,
812        position: LogicalPosition,
813        pressure: f32,
814        tilt: (f32, f32),
815        in_contact: bool,
816        is_eraser: bool,
817        barrel_button_pressed: bool,
818        device_id: u64,
819        tangential_pressure: f32,
820        barrel_roll_rad: f32,
821        tool_id: u32,
822    ) {
823        self.previous_pen_state = self.pen_state.clone();
824        self.pen_state = Some(PenState {
825            position,
826            pressure,
827            tilt: crate::callbacks::PenTilt {
828                x_tilt: tilt.0,
829                y_tilt: tilt.1,
830            },
831            in_contact,
832            is_eraser,
833            barrel_button_pressed,
834            device_id,
835            tangential_pressure,
836            barrel_roll_rad,
837            tool_id,
838        });
839        self.pen_event_pending = true;
840    }
841
842    /// Clear pen state (when pen leaves proximity)
843    pub fn clear_pen_state(&mut self) {
844        self.previous_pen_state = self.pen_state.clone();
845        self.pen_state = None;
846        self.pen_event_pending = true;
847    }
848
849    /// Get current pen state (read-only)
850    pub fn get_pen_state(&self) -> Option<&PenState> {
851        self.pen_state.as_ref()
852    }
853
854    /// Get the previous pen state (for event diffing).
855    pub fn get_previous_pen_state(&self) -> Option<&PenState> {
856        self.previous_pen_state.as_ref()
857    }
858
859    /// Clear the pen-event-pending flag (called by the event loop after a pass).
860    pub fn clear_pen_event_pending(&mut self) {
861        self.pen_event_pending = false;
862    }
863
864    /// Set the latest Wacom tablet-pad state (called by the pad backend).
865    pub fn update_pad_state(&mut self, pad: WacomPadState) {
866        self.pad_state = Some(pad);
867    }
868
869    /// The latest tablet-pad state, or `None` if no pad backend delivered one.
870    pub fn get_pad_state(&self) -> Option<&WacomPadState> {
871        self.pad_state.as_ref()
872    }
873
874    /// Clear the tablet-pad state (pad disconnected / proximity left).
875    pub fn clear_pad_state(&mut self) {
876        self.pad_state = None;
877    }
878
879    // Gesture Detection Methods (query state without mutation)
880
881    /// Detect if current input represents a drag gesture
882    ///
883    /// Returns Some(DetectedDrag) if a drag is detected based on distance threshold.
884    pub fn detect_drag(&self) -> Option<DetectedDrag> {
885        let session = self.get_current_session()?;
886
887        if session.samples.len() < self.config.min_samples_for_gesture {
888            return None;
889        }
890
891        let direct_distance = session.direct_distance()?;
892
893        if direct_distance >= self.config.drag_distance_threshold {
894            let first = session.first_sample()?;
895            let last = session.last_sample()?;
896
897            Some(DetectedDrag {
898                start_position: first.position,
899                current_position: last.position,
900                direct_distance,
901                total_distance: session.total_distance(),
902                duration_ms: session.duration_ms()?,
903                sample_count: session.samples.len(),
904                session_id: session.session_id,
905            })
906        } else {
907            None
908        }
909    }
910
911    /// Detect if current input represents a long press
912    ///
913    /// Returns Some(DetectedLongPress) if button has been held long enough
914    /// without moving much.
915    pub fn detect_long_press(&self) -> Option<DetectedLongPress> {
916        if let Some(NativeGestureEvent::LongPress(lp)) = self.native_gesture {
917            return Some(lp);
918        }
919        let session = self.get_current_session()?;
920
921        if session.ended {
922            return None; // Can't be long press if button already released
923        }
924
925        let duration_ms = session.duration_ms()?;
926
927        if duration_ms < self.config.long_press_time_threshold_ms {
928            return None;
929        }
930
931        let distance = session.direct_distance()?;
932
933        if distance <= self.config.long_press_distance_threshold {
934            let first = session.first_sample()?;
935            let callback_invoked = self
936                .long_press_callbacks_invoked
937                .contains(&session.session_id);
938
939            Some(DetectedLongPress {
940                position: first.position,
941                duration_ms,
942                callback_invoked,
943                session_id: session.session_id,
944            })
945        } else {
946            None
947        }
948    }
949
950    /// Mark long press callback as invoked for a session
951    ///
952    /// Call this after invoking the long press callback to prevent
953    /// repeated invocations.
954    pub fn mark_long_press_callback_invoked(&mut self, session_id: u64) {
955        if !self.long_press_callbacks_invoked.contains(&session_id) {
956            self.long_press_callbacks_invoked.push(session_id);
957        }
958    }
959
960    /// Detect if last two sessions form a double-click.
961    ///
962    /// Returns true if timing and distance match double-click criteria.
963    pub fn detect_double_click(&self) -> bool {
964        if matches!(self.native_gesture, Some(NativeGestureEvent::DoubleClick)) {
965            return true;
966        }
967        let sessions = &self.input_sessions;
968        if sessions.len() < 2 {
969            return false;
970        }
971
972        let prev_session = &sessions[sessions.len() - 2];
973        let last_session = &sessions[sessions.len() - 1];
974
975        // Both sessions must have ended (button released)
976        if !prev_session.ended || !last_session.ended {
977            return false;
978        }
979
980        let prev_first = prev_session.first_sample();
981        let last_first = last_session.first_sample();
982        let (prev_first, last_first) = match (prev_first, last_first) {
983            (Some(p), Some(l)) => (p, l),
984            _ => return false,
985        };
986
987        let duration = last_first.timestamp.duration_since(&prev_first.timestamp);
988        let time_delta_ms = duration_to_millis(duration);
989        if time_delta_ms > self.config.double_click_time_threshold_ms {
990            return false;
991        }
992
993        let dx = last_first.position.x - prev_first.position.x;
994        let dy = last_first.position.y - prev_first.position.y;
995        let distance = (dx * dx + dy * dy).sqrt();
996
997        distance < self.config.double_click_distance_threshold
998    }
999
1000    /// Detect click count (1=single, 2=double, 3=triple) by examining
1001    /// the recent ended sessions.  Uses only timestamps and positions
1002    /// from the session history, so the result is fully deterministic
1003    /// for any given sequence of `InputSession`s (easy to unit-test
1004    /// with synthetic `CoreInstant`/`CoreDuration` values).
1005    pub fn detect_click_count(&self) -> u32 {
1006        let sessions = &self.input_sessions;
1007        let n = sessions.len();
1008        if n == 0 {
1009            return 1;
1010        }
1011
1012        // We need at least 2 ended sessions for double-click,
1013        // 3 ended sessions for triple-click.
1014        // Walk backwards from the most recent ended session and count
1015        // how many consecutive clicks fall within the time+distance
1016        // thresholds.
1017
1018        // Collect the last up-to-3 ended sessions (most-recent first).
1019        let mut recent: Vec<&InputSession> = Vec::new();
1020        for s in sessions.iter().rev() {
1021            if !s.ended {
1022                continue;
1023            }
1024            recent.push(s);
1025            if recent.len() >= 3 {
1026                break;
1027            }
1028        }
1029
1030        if recent.is_empty() {
1031            return 1;
1032        }
1033
1034        // recent[0] = most recent ended session
1035        // recent[1] = previous ended session (if any)
1036        // recent[2] = one before that (if any)
1037        let mut count = 1u32;
1038
1039        for i in 0..recent.len() - 1 {
1040            let later = recent[i];
1041            let earlier = recent[i + 1];
1042
1043            let later_start = match later.first_sample() {
1044                Some(s) => s,
1045                None => break,
1046            };
1047            let earlier_start = match earlier.first_sample() {
1048                Some(s) => s,
1049                None => break,
1050            };
1051
1052            let duration = later_start.timestamp.duration_since(&earlier_start.timestamp);
1053            let time_delta_ms = duration_to_millis(duration);
1054            if time_delta_ms > self.config.double_click_time_threshold_ms {
1055                break;
1056            }
1057
1058            let dx = later_start.position.x - earlier_start.position.x;
1059            let dy = later_start.position.y - earlier_start.position.y;
1060            let distance = (dx * dx + dy * dy).sqrt();
1061            if distance >= self.config.double_click_distance_threshold {
1062                break;
1063            }
1064
1065            count += 1;
1066        }
1067
1068        // Cap at 3 (triple-click selects paragraph, beyond that cycles back)
1069        if count > 3 { 1 } else { count }
1070    }
1071
1072    /// Get the primary direction of current drag.
1073    pub fn get_drag_direction(&self) -> Option<GestureDirection> {
1074        let session = self.get_current_session()?;
1075        let first = session.first_sample()?;
1076        let last = session.last_sample()?;
1077
1078        let dx = last.position.x - first.position.x;
1079        let dy = last.position.y - first.position.y;
1080
1081        let direction = match (dx.abs() > dy.abs(), dx > 0.0, dy > 0.0) {
1082            (true, true, _) => GestureDirection::Right,
1083            (true, false, _) => GestureDirection::Left,
1084            (false, _, true) => GestureDirection::Down,
1085            (false, _, false) => GestureDirection::Up,
1086        };
1087        Some(direction)
1088    }
1089
1090    /// Get average velocity of current gesture (pixels per second)
1091    pub fn get_gesture_velocity(&self) -> Option<f32> {
1092        let session = self.get_current_session()?;
1093
1094        if session.samples.len() < 2 {
1095            return None;
1096        }
1097
1098        let total_distance = session.total_distance();
1099        let duration_ms = session.duration_ms()?;
1100
1101        if duration_ms == 0 {
1102            return None;
1103        }
1104
1105        let duration_secs = duration_ms as f32 / 1000.0;
1106        Some(total_distance / duration_secs)
1107    }
1108
1109    /// Check if current gesture is a swipe (fast directional movement).
1110    pub fn is_swipe(&self) -> bool {
1111        self.get_gesture_velocity()
1112            .map(|v| v >= self.config.swipe_velocity_threshold)
1113            .unwrap_or(false)
1114    }
1115
1116    /// Detect swipe with specific direction
1117    ///
1118    /// Returns Some(dir) if gesture is a fast swipe in a clear direction
1119    pub fn detect_swipe_direction(&self) -> Option<GestureDirection> {
1120        if let Some(NativeGestureEvent::Swipe(d)) = self.native_gesture {
1121            return Some(d);
1122        }
1123        // Must be a fast swipe first
1124        if !self.is_swipe() {
1125            return None;
1126        }
1127
1128        // Get direction
1129        self.get_drag_direction()
1130    }
1131
1132    /// Detect pinch gesture (two-touch zoom in/out)
1133    ///
1134    /// Returns Some if two touch points are active and distance is changing
1135    /// significantly. Scale < 1.0 = pinch in, scale > 1.0 = pinch out.
1136    pub fn detect_pinch(&self) -> Option<DetectedPinch> {
1137        if let Some(NativeGestureEvent::Pinch(p)) = self.native_gesture {
1138            return Some(p);
1139        }
1140        // Need at least two active sessions for pinch
1141        if self.input_sessions.len() < 2 {
1142            return None;
1143        }
1144
1145        // Get last two sessions (most recent touches)
1146        let session1 = &self.input_sessions[self.input_sessions.len() - 2];
1147        let session2 = &self.input_sessions[self.input_sessions.len() - 1];
1148
1149        // Both must have samples
1150        let first1 = session1.first_sample()?;
1151        let first2 = session2.first_sample()?;
1152        let last1 = session1.last_sample()?;
1153        let last2 = session2.last_sample()?;
1154
1155        // Calculate initial distance between touches
1156        let dx_initial = first2.position.x - first1.position.x;
1157        let dy_initial = first2.position.y - first1.position.y;
1158        let initial_distance = (dx_initial * dx_initial + dy_initial * dy_initial).sqrt();
1159
1160        // Calculate current distance
1161        let dx_current = last2.position.x - last1.position.x;
1162        let dy_current = last2.position.y - last1.position.y;
1163        let current_distance = (dx_current * dx_current + dy_current * dy_current).sqrt();
1164
1165        // Avoid division by zero
1166        if initial_distance < 1.0 {
1167            return None;
1168        }
1169
1170        // Calculate scale factor
1171        let scale = current_distance / initial_distance;
1172
1173        // Check if scale change is significant (threshold from config)
1174        let scale_threshold = 1.0 + self.config.pinch_scale_threshold;
1175        if scale > 1.0 / scale_threshold && scale < scale_threshold {
1176            return None; // Change too small
1177        }
1178
1179        // Calculate center point
1180        let center = LogicalPosition {
1181            x: (last1.position.x + last2.position.x) / 2.0,
1182            y: (last1.position.y + last2.position.y) / 2.0,
1183        };
1184
1185        // Calculate duration
1186        let duration = last1.timestamp.duration_since(&first1.timestamp);
1187        let duration_ms = duration_to_millis(duration);
1188
1189        Some(DetectedPinch {
1190            scale,
1191            center,
1192            initial_distance,
1193            current_distance,
1194            duration_ms,
1195        })
1196    }
1197
1198    /// Detect rotation gesture (two-touch rotate)
1199    ///
1200    /// Returns Some if two touch points are rotating around center.
1201    /// Positive angle = clockwise, negative = counterclockwise.
1202    pub fn detect_rotation(&self) -> Option<DetectedRotation> {
1203        if let Some(NativeGestureEvent::Rotation(r)) = self.native_gesture {
1204            return Some(r);
1205        }
1206        // Need at least two active sessions
1207        if self.input_sessions.len() < 2 {
1208            return None;
1209        }
1210
1211        // Get last two sessions
1212        let session1 = &self.input_sessions[self.input_sessions.len() - 2];
1213        let session2 = &self.input_sessions[self.input_sessions.len() - 1];
1214
1215        // Both must have samples
1216        let first1 = session1.first_sample()?;
1217        let first2 = session2.first_sample()?;
1218        let last1 = session1.last_sample()?;
1219        let last2 = session2.last_sample()?;
1220
1221        // Calculate center (average of both touches)
1222        let center = LogicalPosition {
1223            x: (last1.position.x + last2.position.x) / 2.0,
1224            y: (last1.position.y + last2.position.y) / 2.0,
1225        };
1226
1227        // Calculate initial angle between touches
1228        let dx_initial = first2.position.x - first1.position.x;
1229        let dy_initial = first2.position.y - first1.position.y;
1230        let initial_angle = dy_initial.atan2(dx_initial);
1231
1232        // Calculate current angle
1233        let dx_current = last2.position.x - last1.position.x;
1234        let dy_current = last2.position.y - last1.position.y;
1235        let current_angle = dy_current.atan2(dx_current);
1236
1237        // Calculate angle difference (normalized to -π to π)
1238        let mut angle_diff = current_angle - initial_angle;
1239
1240        // Normalize angle to -π to π range
1241        const PI: f32 = core::f32::consts::PI;
1242        while angle_diff > PI {
1243            angle_diff -= 2.0 * PI;
1244        }
1245        while angle_diff < -PI {
1246            angle_diff += 2.0 * PI;
1247        }
1248
1249        // Check if rotation is significant (threshold from config)
1250        if angle_diff.abs() < self.config.rotation_angle_threshold {
1251            return None;
1252        }
1253
1254        // Calculate duration
1255        let duration = last1.timestamp.duration_since(&first1.timestamp);
1256        let duration_ms = duration_to_millis(duration);
1257
1258        Some(DetectedRotation {
1259            angle_radians: angle_diff,
1260            center,
1261            duration_ms,
1262        })
1263    }
1264
1265    /// Get the current active input session (if any)
1266    pub fn get_current_session(&self) -> Option<&InputSession> {
1267        self.input_sessions.last()
1268    }
1269
1270    /// Get current mouse position from latest sample
1271    pub fn get_current_mouse_position(&self) -> Option<LogicalPosition> {
1272        self.get_current_session()
1273            .and_then(|s| s.last_sample())
1274            .map(|sample| sample.position)
1275    }
1276
1277    /// Get the drag delta (current mouse position minus mouse-down position)
1278    /// from the current input session.
1279    ///
1280    /// Returns `None` if there is no active session or not enough samples.
1281    pub fn get_drag_delta(&self) -> Option<(f32, f32)> {
1282        let session = self.get_current_session()?;
1283        let first = session.first_sample()?;
1284        let last = session.last_sample()?;
1285        Some((
1286            last.position.x - first.position.x,
1287            last.position.y - first.position.y,
1288        ))
1289    }
1290
1291    /// Get the drag delta in **screen-absolute** coordinates.
1292    ///
1293    /// Unlike `get_drag_delta()` which uses window-local coordinates (and therefore
1294    /// oscillates during window drags due to the window moving under the cursor),
1295    /// this method uses screen-absolute positions that are stable regardless of
1296    /// window movement.
1297    ///
1298    /// **Use this for window dragging (titlebar drag).**
1299    /// Use `get_drag_delta()` for in-window operations (node drag-and-drop, etc.).
1300    ///
1301    /// Returns `None` if there is no active session or not enough samples.
1302    pub fn get_drag_delta_screen(&self) -> Option<(f32, f32)> {
1303        let session = self.get_current_session()?;
1304        let first = session.first_sample()?;
1305        let last = session.last_sample()?;
1306        Some((
1307            last.screen_position.x - first.screen_position.x,
1308            last.screen_position.y - first.screen_position.y,
1309        ))
1310    }
1311
1312    /// Get the **incremental** (frame-to-frame) drag delta in screen coordinates.
1313    ///
1314    /// Returns `(dx, dy)` where `dx = last_screen.x - previous_screen.x` and
1315    /// `dy = last_screen.y - previous_screen.y`.
1316    ///
1317    /// Unlike `get_drag_delta_screen()` which returns the *total* delta since drag
1318    /// start, this returns only the delta since the previous sample. This is used
1319    /// by `titlebar_drag` to apply position changes incrementally:
1320    ///
1321    /// ```text
1322    /// new_pos = current_window_pos + incremental_delta
1323    /// ```
1324    ///
1325    /// This approach is more robust than `initial_pos + total_delta` because it
1326    /// automatically handles external window position changes (DPI change, OS
1327    /// clamping, compositor resize) that would make `initial_pos` stale.
1328    ///
1329    /// Returns `None` if there is no active session or fewer than 2 samples.
1330    pub fn get_drag_delta_screen_incremental(&self) -> Option<(f32, f32)> {
1331        let session = self.get_current_session()?;
1332        let len = session.samples.len();
1333        if len < 2 {
1334            return None;
1335        }
1336        let prev = &session.samples[len - 2];
1337        let last = &session.samples[len - 1];
1338        Some((
1339            last.screen_position.x - prev.screen_position.x,
1340            last.screen_position.y - prev.screen_position.y,
1341        ))
1342    }
1343
1344    /// Get the window position that was stored when the current input session
1345    /// started (i.e. on mouse-down).  Titlebar drag callbacks use this
1346    /// together with `get_drag_delta_screen()` to compute the new window position.
1347    pub fn get_window_position_at_session_start(&self) -> Option<azul_core::window::WindowPosition> {
1348        let session = self.get_current_session()?;
1349        Some(session.window_position_at_start)
1350    }
1351
1352    // ========================================================================
1353    // UNIFIED DRAG CONTEXT API (NEW)
1354    // ========================================================================
1355
1356    /// Get the active drag context (if any)
1357    pub fn get_drag_context(&self) -> Option<&DragContext> {
1358        self.active_drag.as_ref()
1359    }
1360
1361    /// Get the active drag context mutably (if any)
1362    pub fn get_drag_context_mut(&mut self) -> Option<&mut DragContext> {
1363        self.active_drag.as_mut()
1364    }
1365
1366    /// Activate a text selection drag
1367    pub fn activate_text_selection_drag(
1368        &mut self,
1369        dom_id: DomId,
1370        anchor_ifc_node: NodeId,
1371        start_mouse_position: LogicalPosition,
1372    ) {
1373        let session_id = self.current_session_id().unwrap_or(0);
1374        self.active_drag = Some(DragContext::text_selection(
1375            dom_id,
1376            anchor_ifc_node,
1377            start_mouse_position,
1378            session_id,
1379        ));
1380    }
1381
1382    /// Activate a scrollbar thumb drag
1383    pub fn activate_scrollbar_drag(
1384        &mut self,
1385        scroll_container_node: NodeId,
1386        axis: ScrollbarAxis,
1387        start_mouse_position: LogicalPosition,
1388        start_scroll_offset: f32,
1389        track_length_px: f32,
1390        content_length_px: f32,
1391        viewport_length_px: f32,
1392    ) {
1393        let session_id = self.current_session_id().unwrap_or(0);
1394        self.active_drag = Some(DragContext::scrollbar_thumb(
1395            scroll_container_node,
1396            axis,
1397            start_mouse_position,
1398            start_scroll_offset,
1399            track_length_px,
1400            content_length_px,
1401            viewport_length_px,
1402            session_id,
1403        ));
1404    }
1405
1406    /// Activate a node drag-and-drop
1407    pub fn activate_node_drag(
1408        &mut self,
1409        dom_id: DomId,
1410        node_id: NodeId,
1411        drag_data: DragData,
1412        _start_hit_test: Option<HitTest>,
1413    ) {
1414        if let Some(detected) = self.detect_drag() {
1415            self.active_drag = Some(DragContext::node_drag(
1416                dom_id,
1417                node_id,
1418                detected.start_position,
1419                drag_data,
1420                detected.session_id,
1421            ));
1422        }
1423    }
1424
1425    /// Activate a window move drag (titlebar)
1426    pub fn activate_window_drag(
1427        &mut self,
1428        initial_window_position: WindowPosition,
1429        _start_hit_test: Option<HitTest>,
1430    ) {
1431        if let Some(detected) = self.detect_drag() {
1432            self.active_drag = Some(DragContext::window_move(
1433                detected.start_position,
1434                initial_window_position,
1435                detected.session_id,
1436            ));
1437        }
1438    }
1439
1440    /// Start file drop from OS
1441    pub fn start_file_drop(&mut self, files: Vec<AzString>, position: LogicalPosition) {
1442        let session_id = self.current_session_id().unwrap_or(0);
1443        self.active_drag = Some(DragContext::file_drop(files, position, session_id));
1444    }
1445
1446    /// Update positions for active drag (call on mouse move)
1447    pub fn update_active_drag_positions(&mut self, position: LogicalPosition) {
1448        if let Some(ref mut drag) = self.active_drag {
1449            drag.update_position(position);
1450        }
1451    }
1452
1453    /// Update drop target for node or file drag
1454    pub fn update_drop_target(&mut self, target: Option<azul_core::dom::DomNodeId>) {
1455        if let Some(ref mut drag) = self.active_drag {
1456            match &mut drag.drag_type {
1457                ActiveDragType::Node(ref mut node_drag) => {
1458                    node_drag.current_drop_target = target.into();
1459                }
1460                ActiveDragType::FileDrop(ref mut file_drop) => {
1461                    file_drop.drop_target = target.into();
1462                }
1463                _ => {}
1464            }
1465        }
1466    }
1467
1468    /// Update auto-scroll direction for text selection drag
1469    pub fn update_auto_scroll_direction(&mut self, direction: AutoScrollDirection) {
1470        if let Some(ref mut drag) = self.active_drag {
1471            if let Some(text_drag) = drag.as_text_selection_mut() {
1472                text_drag.auto_scroll_direction = direction;
1473            }
1474        }
1475    }
1476
1477    /// End the current drag and return the context
1478    pub fn end_drag(&mut self) -> Option<DragContext> {
1479        self.active_drag.take()
1480    }
1481
1482    /// Cancel the current drag
1483    pub fn cancel_drag(&mut self) {
1484        if let Some(ref mut drag) = self.active_drag {
1485            drag.cancelled = true;
1486        }
1487        self.active_drag = None;
1488    }
1489
1490    // ========================================================================
1491    // QUERY METHODS
1492    // ========================================================================
1493
1494    /// Check if any drag operation is in progress
1495    pub fn is_dragging(&self) -> bool {
1496        self.active_drag.is_some()
1497    }
1498
1499    /// Check if a text selection drag is active
1500    pub fn is_text_selection_dragging(&self) -> bool {
1501        self.active_drag.as_ref().is_some_and(|d| d.is_text_selection())
1502    }
1503
1504    /// Check if a scrollbar thumb drag is active
1505    pub fn is_scrollbar_dragging(&self) -> bool {
1506        self.active_drag.as_ref().is_some_and(|d| d.is_scrollbar_thumb())
1507    }
1508
1509    /// Check if a node drag is active
1510    pub fn is_node_dragging_any(&self) -> bool {
1511        self.active_drag.as_ref().is_some_and(|d| d.is_node_drag())
1512    }
1513
1514    /// Check if a node drag is active (alias for event determination)
1515    pub fn is_node_drag_active(&self) -> bool {
1516        self.is_node_dragging_any()
1517    }
1518
1519    /// Check if a specific node is being dragged
1520    pub fn is_node_dragging(&self, dom_id: DomId, node_id: NodeId) -> bool {
1521        self.active_drag.as_ref().is_some_and(|d| {
1522            if let Some(node_drag) = d.as_node_drag() {
1523                node_drag.dom_id == dom_id && node_drag.node_id == node_id
1524            } else {
1525                false
1526            }
1527        })
1528    }
1529
1530    /// Check if window drag is active
1531    pub fn is_window_dragging(&self) -> bool {
1532        self.active_drag.as_ref().is_some_and(|d| d.is_window_move())
1533    }
1534
1535    /// Check if file drop is active
1536    pub fn is_file_dropping(&self) -> bool {
1537        self.active_drag.as_ref().is_some_and(|d| d.is_file_drop())
1538    }
1539
1540    /// Get number of active input sessions
1541    pub fn session_count(&self) -> usize {
1542        self.input_sessions.len()
1543    }
1544
1545    /// Get current session ID (if any)
1546    pub fn current_session_id(&self) -> Option<u64> {
1547        self.get_current_session().map(|s| s.session_id)
1548    }
1549
1550    // ========================================================================
1551    // BACKWARDS COMPATIBILITY (DEPRECATED)
1552    // ========================================================================
1553
1554    /// Get current node drag state (if any)
1555    /// DEPRECATED: Use `get_drag_context()` and check for `ActiveDragType::Node`
1556    pub fn get_node_drag(&self) -> Option<&NodeDrag> {
1557        self.active_drag.as_ref().and_then(|d| d.as_node_drag())
1558    }
1559
1560    /// Get current window drag state (if any)
1561    /// DEPRECATED: Use `get_drag_context()` and check for `ActiveDragType::WindowMove`
1562    pub fn get_window_drag(&self) -> Option<&WindowMoveDrag> {
1563        self.active_drag.as_ref().and_then(|d| d.as_window_move())
1564    }
1565
1566    /// Get current file drop state (if any)
1567    /// DEPRECATED: Use `get_drag_context()` and check for `ActiveDragType::FileDrop`
1568    pub fn get_file_drop(&self) -> Option<&FileDropDrag> {
1569        self.active_drag.as_ref().and_then(|d| d.as_file_drop())
1570    }
1571
1572    /// End node drag (returns None - use end_drag() instead)
1573    /// DEPRECATED: Use `end_drag()` instead
1574    pub fn end_node_drag(&mut self) -> Option<DragContext> {
1575        if self.active_drag.as_ref().is_some_and(|d| d.is_node_drag()) {
1576            self.end_drag()
1577        } else {
1578            None
1579        }
1580    }
1581
1582    /// End window drag (returns None - use end_drag() instead)
1583    /// DEPRECATED: Use `end_drag()` instead
1584    pub fn end_window_drag(&mut self) -> Option<DragContext> {
1585        if self.active_drag.as_ref().is_some_and(|d| d.is_window_move()) {
1586            self.end_drag()
1587        } else {
1588            None
1589        }
1590    }
1591
1592    /// End file drop (returns None - use end_drag() instead)
1593    /// DEPRECATED: Use `end_drag()` instead
1594    pub fn end_file_drop(&mut self) -> Option<DragContext> {
1595        if self.active_drag.as_ref().is_some_and(|d| d.is_file_drop()) {
1596            self.end_drag()
1597        } else {
1598            None
1599        }
1600    }
1601
1602    /// Cancel file drop
1603    /// DEPRECATED: Use `cancel_drag()` instead
1604    pub fn cancel_file_drop(&mut self) {
1605        if self.active_drag.as_ref().is_some_and(|d| d.is_file_drop()) {
1606            self.cancel_drag();
1607        }
1608    }
1609
1610    // ========================================================================
1611    // WINDOW DRAG HELPER METHODS
1612    // ========================================================================
1613
1614    /// Calculate window position delta from current drag state
1615    ///
1616    /// Returns (delta_x, delta_y) to apply to window position.
1617    /// Returns None if no window drag is active or drag hasn't moved.
1618    pub fn get_window_drag_delta(&self) -> Option<(i32, i32)> {
1619        let drag = self.active_drag.as_ref()?.as_window_move()?;
1620
1621        let delta_x = drag.current_position.x - drag.start_position.x;
1622        let delta_y = drag.current_position.y - drag.start_position.y;
1623
1624        match drag.initial_window_position {
1625            WindowPosition::Initialized(_initial_pos) => Some((delta_x as i32, delta_y as i32)),
1626            _ => None,
1627        }
1628    }
1629
1630    /// Get the new window position based on current drag
1631    ///
1632    /// Returns the absolute window position to set.
1633    pub fn get_window_position_from_drag(&self) -> Option<WindowPosition> {
1634        let drag = self.active_drag.as_ref()?.as_window_move()?;
1635
1636        let delta_x = drag.current_position.x - drag.start_position.x;
1637        let delta_y = drag.current_position.y - drag.start_position.y;
1638
1639        match drag.initial_window_position {
1640            WindowPosition::Initialized(initial_pos) => {
1641                Some(WindowPosition::Initialized(PhysicalPositionI32::new(
1642                    initial_pos.x + delta_x as i32,
1643                    initial_pos.y + delta_y as i32,
1644                )))
1645            }
1646            _ => None,
1647        }
1648    }
1649
1650    /// Calculate the new scroll offset for scrollbar thumb drag
1651    pub fn get_scrollbar_scroll_offset(&self) -> Option<f32> {
1652        self.active_drag.as_ref()?.calculate_scrollbar_scroll_offset()
1653    }
1654
1655    /// Remap NodeIds in active drag context after DOM reconciliation.
1656    ///
1657    /// When the DOM is regenerated during an active drag, NodeIds can change.
1658    /// If a critical NodeId was removed, the drag is cancelled.
1659    pub fn remap_node_ids(
1660        &mut self,
1661        dom_id: azul_core::dom::DomId,
1662        node_id_map: &std::collections::BTreeMap<azul_core::id::NodeId, azul_core::id::NodeId>,
1663    ) {
1664        if let Some(ref mut drag) = self.active_drag {
1665            if !drag.remap_node_ids(dom_id, node_id_map) {
1666                // Critical node removed — cancel the drag
1667                drag.cancelled = true;
1668                self.active_drag = None;
1669            }
1670        }
1671    }
1672}