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

            
16
use alloc::{collections::btree_map::BTreeMap, vec::Vec};
17
#[cfg(feature = "std")]
18
use std::sync::atomic::{AtomicU64, Ordering};
19

            
20
use 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
};
33
use azul_css::AzString;
34
use azul_css::{impl_option, impl_option_inner, StringVec};
35

            
36
// Re-export drag types for convenience
37
pub 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")]
44
static NEXT_EVENT_ID: AtomicU64 = AtomicU64::new(1);
45

            
46
/// Allocate a new unique event ID
47
#[cfg(feature = "std")]
48
pub 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"))]
54
pub 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.
62
fn 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.
87
pub 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.
93
pub 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.
98
const DRAIN_BATCH_SIZE: usize = 100;
99

            
100
/// Configuration for gesture detection thresholds
101
#[derive(Debug, Clone, Copy, PartialEq)]
102
pub 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

            
125
impl Default for GestureDetectionConfig {
126
2356
    fn default() -> Self {
127
2356
        Self {
128
2356
            drag_distance_threshold: 5.0,
129
2356
            double_click_time_threshold_ms: 500,
130
2356
            double_click_distance_threshold: 5.0,
131
2356
            long_press_time_threshold_ms: 500,
132
2356
            long_press_distance_threshold: 10.0,
133
2356
            min_samples_for_gesture: 2,
134
2356
            swipe_velocity_threshold: 500.0, // 500 px/s
135
2356
            pinch_scale_threshold: 0.1,      // 10% scale change
136
2356
            rotation_angle_threshold: 0.1,   // ~5.7 degrees in radians
137
2356
            sample_cleanup_interval_ms: DEFAULT_SAMPLE_TIMEOUT_MS,
138
2356
        }
139
2356
    }
140
}
141

            
142
/// Single input sample with position and timestamp
143
#[derive(Debug, Clone, PartialEq)]
144
pub 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

            
172
impl_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)]
181
pub 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

            
193
impl 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)]
251
pub 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)]
271
pub 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)]
285
pub enum GestureDirection {
286
    Up,
287
    Down,
288
    Left,
289
    Right,
290
}
291

            
292
impl_option!(
293
    GestureDirection,
294
    OptionGestureDirection,
295
    [Debug, Clone, Copy, PartialEq, Eq]
296
);
297
impl_option!(
298
    DetectedPinch,
299
    OptionDetectedPinch,
300
    [Debug, Clone, Copy, PartialEq]
301
);
302
impl_option!(
303
    DetectedRotation,
304
    OptionDetectedRotation,
305
    [Debug, Clone, Copy, PartialEq]
306
);
307
impl_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)]
316
pub 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)]
332
pub 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.
347
pub type NodeDragState = NodeDrag;
348

            
349
/// State of window being dragged (titlebar drag)
350
/// DEPRECATED: Use `DragContext` with `ActiveDragType::WindowMove` instead.
351
pub type WindowDragState = WindowMoveDrag;
352

            
353
/// State of file(s) being dragged from OS over the window
354
/// DEPRECATED: Use `DragContext` with `ActiveDragType::FileDrop` instead.
355
pub type FileDropState = FileDropDrag;
356

            
357
/// State of pen/stylus input
358
#[derive(Debug, Clone, Copy, PartialEq)]
359
#[repr(C)]
360
pub 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

            
394
impl_option!(PenState, OptionPenState, [Debug, Clone, Copy, PartialEq]);
395

            
396
impl 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)]
423
pub 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

            
436
impl_option!(
437
    WacomPadState,
438
    OptionWacomPadState,
439
    [Debug, Clone, Copy, PartialEq]
440
);
441

            
442
impl 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

            
453
impl 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)]
474
pub 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)]
521
pub 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
539
pub type GestureManager = GestureAndDragManager;
540

            
541
impl Default for GestureAndDragManager {
542
    fn default() -> Self {
543
        Self::new()
544
    }
545
}
546

            
547
impl 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
2356
    pub fn new() -> Self {
556
2356
        Self {
557
2356
            config: GestureDetectionConfig::default(),
558
2356
            input_sessions: Vec::new(),
559
2356
            next_session_id: 1,
560
2356
            active_drag: None,
561
2356
            pen_state: None,
562
2356
            previous_pen_state: None,
563
2356
            pen_event_pending: false,
564
2356
            pad_state: None,
565
2356
            long_press_callbacks_invoked: Vec::new(),
566
2356
            native_gesture: None,
567
2356
        }
568
2356
    }
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
1
    pub fn update_pen_state(
785
1
        &mut self,
786
1
        position: LogicalPosition,
787
1
        pressure: f32,
788
1
        tilt: (f32, f32),
789
1
        in_contact: bool,
790
1
        is_eraser: bool,
791
1
        barrel_button_pressed: bool,
792
1
        device_id: u64,
793
1
    ) {
794
1
        self.update_pen_state_full(
795
1
            position,
796
1
            pressure,
797
1
            tilt,
798
1
            in_contact,
799
1
            is_eraser,
800
1
            barrel_button_pressed,
801
1
            device_id,
802
            0.0,
803
            0.0,
804
            0,
805
        );
806
1
    }
807

            
808
    /// Update pen/stylus state including the extended axes (W3C
809
    /// `PointerEvent.tangentialPressure` + `twist`) and per-tool id.
810
1
    pub fn update_pen_state_full(
811
1
        &mut self,
812
1
        position: LogicalPosition,
813
1
        pressure: f32,
814
1
        tilt: (f32, f32),
815
1
        in_contact: bool,
816
1
        is_eraser: bool,
817
1
        barrel_button_pressed: bool,
818
1
        device_id: u64,
819
1
        tangential_pressure: f32,
820
1
        barrel_roll_rad: f32,
821
1
        tool_id: u32,
822
1
    ) {
823
1
        self.previous_pen_state = self.pen_state.clone();
824
1
        self.pen_state = Some(PenState {
825
1
            position,
826
1
            pressure,
827
1
            tilt: crate::callbacks::PenTilt {
828
1
                x_tilt: tilt.0,
829
1
                y_tilt: tilt.1,
830
1
            },
831
1
            in_contact,
832
1
            is_eraser,
833
1
            barrel_button_pressed,
834
1
            device_id,
835
1
            tangential_pressure,
836
1
            barrel_roll_rad,
837
1
            tool_id,
838
1
        });
839
1
        self.pen_event_pending = true;
840
1
    }
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
35
    pub fn get_pen_state(&self) -> Option<&PenState> {
851
35
        self.pen_state.as_ref()
852
35
    }
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

            
    /// Detect click count (1=single, 2=double, 3=triple) by examining
    /// the recent ended sessions.  Uses only timestamps and positions
    /// from the session history, so the result is fully deterministic
    /// for any given sequence of `InputSession`s (easy to unit-test
    /// with synthetic `CoreInstant`/`CoreDuration` values).
    pub fn detect_click_count(&self) -> u32 {
        let sessions = &self.input_sessions;
        let n = sessions.len();
        if n == 0 {
            return 1;
        }
        // We need at least 2 ended sessions for double-click,
        // 3 ended sessions for triple-click.
        // Walk backwards from the most recent ended session and count
        // how many consecutive clicks fall within the time+distance
        // thresholds.
        // Collect the last up-to-3 ended sessions (most-recent first).
        let mut recent: Vec<&InputSession> = Vec::new();
        for s in sessions.iter().rev() {
            if !s.ended {
                continue;
            }
            recent.push(s);
            if recent.len() >= 3 {
                break;
            }
        }
        if recent.is_empty() {
            return 1;
        }
        // recent[0] = most recent ended session
        // recent[1] = previous ended session (if any)
        // recent[2] = one before that (if any)
        let mut count = 1u32;
        for i in 0..recent.len() - 1 {
            let later = recent[i];
            let earlier = recent[i + 1];
            let later_start = match later.first_sample() {
                Some(s) => s,
                None => break,
            };
            let earlier_start = match earlier.first_sample() {
                Some(s) => s,
                None => break,
            };
            let duration = later_start.timestamp.duration_since(&earlier_start.timestamp);
            let time_delta_ms = duration_to_millis(duration);
            if time_delta_ms > self.config.double_click_time_threshold_ms {
                break;
            }
            let dx = later_start.position.x - earlier_start.position.x;
            let dy = later_start.position.y - earlier_start.position.y;
            let distance = (dx * dx + dy * dy).sqrt();
            if distance >= self.config.double_click_distance_threshold {
                break;
            }
            count += 1;
        }
        // Cap at 3 (triple-click selects paragraph, beyond that cycles back)
        if count > 3 { 1 } else { count }
    }
    /// Get the primary direction of current drag.
    pub fn get_drag_direction(&self) -> Option<GestureDirection> {
        let session = self.get_current_session()?;
        let first = session.first_sample()?;
        let last = session.last_sample()?;
        let dx = last.position.x - first.position.x;
        let dy = last.position.y - first.position.y;
        let direction = match (dx.abs() > dy.abs(), dx > 0.0, dy > 0.0) {
            (true, true, _) => GestureDirection::Right,
            (true, false, _) => GestureDirection::Left,
            (false, _, true) => GestureDirection::Down,
            (false, _, false) => GestureDirection::Up,
        };
        Some(direction)
    }
    /// Get average velocity of current gesture (pixels per second)
    pub fn get_gesture_velocity(&self) -> Option<f32> {
        let session = self.get_current_session()?;
        if session.samples.len() < 2 {
            return None;
        }
        let total_distance = session.total_distance();
        let duration_ms = session.duration_ms()?;
        if duration_ms == 0 {
            return None;
        }
        let duration_secs = duration_ms as f32 / 1000.0;
        Some(total_distance / duration_secs)
    }
    /// Check if current gesture is a swipe (fast directional movement).
    pub fn is_swipe(&self) -> bool {
        self.get_gesture_velocity()
            .map(|v| v >= self.config.swipe_velocity_threshold)
            .unwrap_or(false)
    }
    /// Detect swipe with specific direction
    ///
    /// Returns Some(dir) if gesture is a fast swipe in a clear direction
    pub fn detect_swipe_direction(&self) -> Option<GestureDirection> {
        if let Some(NativeGestureEvent::Swipe(d)) = self.native_gesture {
            return Some(d);
        }
        // Must be a fast swipe first
        if !self.is_swipe() {
            return None;
        }
        // Get direction
        self.get_drag_direction()
    }
    /// Detect pinch gesture (two-touch zoom in/out)
    ///
    /// Returns Some if two touch points are active and distance is changing
    /// significantly. Scale < 1.0 = pinch in, scale > 1.0 = pinch out.
    pub fn detect_pinch(&self) -> Option<DetectedPinch> {
        if let Some(NativeGestureEvent::Pinch(p)) = self.native_gesture {
            return Some(p);
        }
        // Need at least two active sessions for pinch
        if self.input_sessions.len() < 2 {
            return None;
        }
        // Get last two sessions (most recent touches)
        let session1 = &self.input_sessions[self.input_sessions.len() - 2];
        let session2 = &self.input_sessions[self.input_sessions.len() - 1];
        // Both must have samples
        let first1 = session1.first_sample()?;
        let first2 = session2.first_sample()?;
        let last1 = session1.last_sample()?;
        let last2 = session2.last_sample()?;
        // Calculate initial distance between touches
        let dx_initial = first2.position.x - first1.position.x;
        let dy_initial = first2.position.y - first1.position.y;
        let initial_distance = (dx_initial * dx_initial + dy_initial * dy_initial).sqrt();
        // Calculate current distance
        let dx_current = last2.position.x - last1.position.x;
        let dy_current = last2.position.y - last1.position.y;
        let current_distance = (dx_current * dx_current + dy_current * dy_current).sqrt();
        // Avoid division by zero
        if initial_distance < 1.0 {
            return None;
        }
        // Calculate scale factor
        let scale = current_distance / initial_distance;
        // Check if scale change is significant (threshold from config)
        let scale_threshold = 1.0 + self.config.pinch_scale_threshold;
        if scale > 1.0 / scale_threshold && scale < scale_threshold {
            return None; // Change too small
        }
        // Calculate center point
        let center = LogicalPosition {
            x: (last1.position.x + last2.position.x) / 2.0,
            y: (last1.position.y + last2.position.y) / 2.0,
        };
        // Calculate duration
        let duration = last1.timestamp.duration_since(&first1.timestamp);
        let duration_ms = duration_to_millis(duration);
        Some(DetectedPinch {
            scale,
            center,
            initial_distance,
            current_distance,
            duration_ms,
        })
    }
    /// Detect rotation gesture (two-touch rotate)
    ///
    /// Returns Some if two touch points are rotating around center.
    /// Positive angle = clockwise, negative = counterclockwise.
    pub fn detect_rotation(&self) -> Option<DetectedRotation> {
        if let Some(NativeGestureEvent::Rotation(r)) = self.native_gesture {
            return Some(r);
        }
        // Need at least two active sessions
        if self.input_sessions.len() < 2 {
            return None;
        }
        // Get last two sessions
        let session1 = &self.input_sessions[self.input_sessions.len() - 2];
        let session2 = &self.input_sessions[self.input_sessions.len() - 1];
        // Both must have samples
        let first1 = session1.first_sample()?;
        let first2 = session2.first_sample()?;
        let last1 = session1.last_sample()?;
        let last2 = session2.last_sample()?;
        // Calculate center (average of both touches)
        let center = LogicalPosition {
            x: (last1.position.x + last2.position.x) / 2.0,
            y: (last1.position.y + last2.position.y) / 2.0,
        };
        // Calculate initial angle between touches
        let dx_initial = first2.position.x - first1.position.x;
        let dy_initial = first2.position.y - first1.position.y;
        let initial_angle = dy_initial.atan2(dx_initial);
        // Calculate current angle
        let dx_current = last2.position.x - last1.position.x;
        let dy_current = last2.position.y - last1.position.y;
        let current_angle = dy_current.atan2(dx_current);
        // Calculate angle difference (normalized to -π to π)
        let mut angle_diff = current_angle - initial_angle;
        // Normalize angle to -π to π range
        const PI: f32 = core::f32::consts::PI;
        while angle_diff > PI {
            angle_diff -= 2.0 * PI;
        }
        while angle_diff < -PI {
            angle_diff += 2.0 * PI;
        }
        // Check if rotation is significant (threshold from config)
        if angle_diff.abs() < self.config.rotation_angle_threshold {
            return None;
        }
        // Calculate duration
        let duration = last1.timestamp.duration_since(&first1.timestamp);
        let duration_ms = duration_to_millis(duration);
        Some(DetectedRotation {
            angle_radians: angle_diff,
            center,
            duration_ms,
        })
    }
    /// Get the current active input session (if any)
    pub fn get_current_session(&self) -> Option<&InputSession> {
        self.input_sessions.last()
    }
    /// Get current mouse position from latest sample
    pub fn get_current_mouse_position(&self) -> Option<LogicalPosition> {
        self.get_current_session()
            .and_then(|s| s.last_sample())
            .map(|sample| sample.position)
    }
    /// Get the drag delta (current mouse position minus mouse-down position)
    /// from the current input session.
    ///
    /// Returns `None` if there is no active session or not enough samples.
    pub fn get_drag_delta(&self) -> Option<(f32, f32)> {
        let session = self.get_current_session()?;
        let first = session.first_sample()?;
        let last = session.last_sample()?;
        Some((
            last.position.x - first.position.x,
            last.position.y - first.position.y,
        ))
    }
    /// Get the drag delta in **screen-absolute** coordinates.
    ///
    /// Unlike `get_drag_delta()` which uses window-local coordinates (and therefore
    /// oscillates during window drags due to the window moving under the cursor),
    /// this method uses screen-absolute positions that are stable regardless of
    /// window movement.
    ///
    /// **Use this for window dragging (titlebar drag).**
    /// Use `get_drag_delta()` for in-window operations (node drag-and-drop, etc.).
    ///
    /// Returns `None` if there is no active session or not enough samples.
    pub fn get_drag_delta_screen(&self) -> Option<(f32, f32)> {
        let session = self.get_current_session()?;
        let first = session.first_sample()?;
        let last = session.last_sample()?;
        Some((
            last.screen_position.x - first.screen_position.x,
            last.screen_position.y - first.screen_position.y,
        ))
    }
    /// Get the **incremental** (frame-to-frame) drag delta in screen coordinates.
    ///
    /// Returns `(dx, dy)` where `dx = last_screen.x - previous_screen.x` and
    /// `dy = last_screen.y - previous_screen.y`.
    ///
    /// Unlike `get_drag_delta_screen()` which returns the *total* delta since drag
    /// start, this returns only the delta since the previous sample. This is used
    /// by `titlebar_drag` to apply position changes incrementally:
    ///
    /// ```text
    /// new_pos = current_window_pos + incremental_delta
    /// ```
    ///
    /// This approach is more robust than `initial_pos + total_delta` because it
    /// automatically handles external window position changes (DPI change, OS
    /// clamping, compositor resize) that would make `initial_pos` stale.
    ///
    /// Returns `None` if there is no active session or fewer than 2 samples.
    pub fn get_drag_delta_screen_incremental(&self) -> Option<(f32, f32)> {
        let session = self.get_current_session()?;
        let len = session.samples.len();
        if len < 2 {
            return None;
        }
        let prev = &session.samples[len - 2];
        let last = &session.samples[len - 1];
        Some((
            last.screen_position.x - prev.screen_position.x,
            last.screen_position.y - prev.screen_position.y,
        ))
    }
    /// Get the window position that was stored when the current input session
    /// started (i.e. on mouse-down).  Titlebar drag callbacks use this
    /// together with `get_drag_delta_screen()` to compute the new window position.
    pub fn get_window_position_at_session_start(&self) -> Option<azul_core::window::WindowPosition> {
        let session = self.get_current_session()?;
        Some(session.window_position_at_start)
    }
    // ========================================================================
    // UNIFIED DRAG CONTEXT API (NEW)
    // ========================================================================
    /// Get the active drag context (if any)
    pub fn get_drag_context(&self) -> Option<&DragContext> {
        self.active_drag.as_ref()
    }
    /// Get the active drag context mutably (if any)
    pub fn get_drag_context_mut(&mut self) -> Option<&mut DragContext> {
        self.active_drag.as_mut()
    }
    /// Activate a text selection drag
    pub fn activate_text_selection_drag(
        &mut self,
        dom_id: DomId,
        anchor_ifc_node: NodeId,
        start_mouse_position: LogicalPosition,
    ) {
        let session_id = self.current_session_id().unwrap_or(0);
        self.active_drag = Some(DragContext::text_selection(
            dom_id,
            anchor_ifc_node,
            start_mouse_position,
            session_id,
        ));
    }
    /// Activate a scrollbar thumb drag
    pub fn activate_scrollbar_drag(
        &mut self,
        scroll_container_node: NodeId,
        axis: ScrollbarAxis,
        start_mouse_position: LogicalPosition,
        start_scroll_offset: f32,
        track_length_px: f32,
        content_length_px: f32,
        viewport_length_px: f32,
    ) {
        let session_id = self.current_session_id().unwrap_or(0);
        self.active_drag = Some(DragContext::scrollbar_thumb(
            scroll_container_node,
            axis,
            start_mouse_position,
            start_scroll_offset,
            track_length_px,
            content_length_px,
            viewport_length_px,
            session_id,
        ));
    }
    /// Activate a node drag-and-drop
    pub fn activate_node_drag(
        &mut self,
        dom_id: DomId,
        node_id: NodeId,
        drag_data: DragData,
        _start_hit_test: Option<HitTest>,
    ) {
        if let Some(detected) = self.detect_drag() {
            self.active_drag = Some(DragContext::node_drag(
                dom_id,
                node_id,
                detected.start_position,
                drag_data,
                detected.session_id,
            ));
        }
    }
    /// Activate a window move drag (titlebar)
    pub fn activate_window_drag(
        &mut self,
        initial_window_position: WindowPosition,
        _start_hit_test: Option<HitTest>,
    ) {
        if let Some(detected) = self.detect_drag() {
            self.active_drag = Some(DragContext::window_move(
                detected.start_position,
                initial_window_position,
                detected.session_id,
            ));
        }
    }
    /// Start file drop from OS
    pub fn start_file_drop(&mut self, files: Vec<AzString>, position: LogicalPosition) {
        let session_id = self.current_session_id().unwrap_or(0);
        self.active_drag = Some(DragContext::file_drop(files, position, session_id));
    }
    /// Update positions for active drag (call on mouse move)
    pub fn update_active_drag_positions(&mut self, position: LogicalPosition) {
        if let Some(ref mut drag) = self.active_drag {
            drag.update_position(position);
        }
    }
    /// Update drop target for node or file drag
    pub fn update_drop_target(&mut self, target: Option<azul_core::dom::DomNodeId>) {
        if let Some(ref mut drag) = self.active_drag {
            match &mut drag.drag_type {
                ActiveDragType::Node(ref mut node_drag) => {
                    node_drag.current_drop_target = target.into();
                }
                ActiveDragType::FileDrop(ref mut file_drop) => {
                    file_drop.drop_target = target.into();
                }
                _ => {}
            }
        }
    }
    /// Update auto-scroll direction for text selection drag
    pub fn update_auto_scroll_direction(&mut self, direction: AutoScrollDirection) {
        if let Some(ref mut drag) = self.active_drag {
            if let Some(text_drag) = drag.as_text_selection_mut() {
                text_drag.auto_scroll_direction = direction;
            }
        }
    }
    /// End the current drag and return the context
    pub fn end_drag(&mut self) -> Option<DragContext> {
        self.active_drag.take()
    }
    /// Cancel the current drag
    pub fn cancel_drag(&mut self) {
        if let Some(ref mut drag) = self.active_drag {
            drag.cancelled = true;
        }
        self.active_drag = None;
    }
    // ========================================================================
    // QUERY METHODS
    // ========================================================================
    /// Check if any drag operation is in progress
    pub fn is_dragging(&self) -> bool {
        self.active_drag.is_some()
    }
    /// Check if a text selection drag is active
    pub fn is_text_selection_dragging(&self) -> bool {
        self.active_drag.as_ref().is_some_and(|d| d.is_text_selection())
    }
    /// Check if a scrollbar thumb drag is active
    pub fn is_scrollbar_dragging(&self) -> bool {
        self.active_drag.as_ref().is_some_and(|d| d.is_scrollbar_thumb())
    }
    /// Check if a node drag is active
    pub fn is_node_dragging_any(&self) -> bool {
        self.active_drag.as_ref().is_some_and(|d| d.is_node_drag())
    }
    /// Check if a node drag is active (alias for event determination)
    pub fn is_node_drag_active(&self) -> bool {
        self.is_node_dragging_any()
    }
    /// Check if a specific node is being dragged
    pub fn is_node_dragging(&self, dom_id: DomId, node_id: NodeId) -> bool {
        self.active_drag.as_ref().is_some_and(|d| {
            if let Some(node_drag) = d.as_node_drag() {
                node_drag.dom_id == dom_id && node_drag.node_id == node_id
            } else {
                false
            }
        })
    }
    /// Check if window drag is active
    pub fn is_window_dragging(&self) -> bool {
        self.active_drag.as_ref().is_some_and(|d| d.is_window_move())
    }
    /// Check if file drop is active
    pub fn is_file_dropping(&self) -> bool {
        self.active_drag.as_ref().is_some_and(|d| d.is_file_drop())
    }
    /// Get number of active input sessions
    pub fn session_count(&self) -> usize {
        self.input_sessions.len()
    }
    /// Get current session ID (if any)
    pub fn current_session_id(&self) -> Option<u64> {
        self.get_current_session().map(|s| s.session_id)
    }
    // ========================================================================
    // BACKWARDS COMPATIBILITY (DEPRECATED)
    // ========================================================================
    /// Get current node drag state (if any)
    /// DEPRECATED: Use `get_drag_context()` and check for `ActiveDragType::Node`
    pub fn get_node_drag(&self) -> Option<&NodeDrag> {
        self.active_drag.as_ref().and_then(|d| d.as_node_drag())
    }
    /// Get current window drag state (if any)
    /// DEPRECATED: Use `get_drag_context()` and check for `ActiveDragType::WindowMove`
    pub fn get_window_drag(&self) -> Option<&WindowMoveDrag> {
        self.active_drag.as_ref().and_then(|d| d.as_window_move())
    }
    /// Get current file drop state (if any)
    /// DEPRECATED: Use `get_drag_context()` and check for `ActiveDragType::FileDrop`
    pub fn get_file_drop(&self) -> Option<&FileDropDrag> {
        self.active_drag.as_ref().and_then(|d| d.as_file_drop())
    }
    /// End node drag (returns None - use end_drag() instead)
    /// DEPRECATED: Use `end_drag()` instead
    pub fn end_node_drag(&mut self) -> Option<DragContext> {
        if self.active_drag.as_ref().is_some_and(|d| d.is_node_drag()) {
            self.end_drag()
        } else {
            None
        }
    }
    /// End window drag (returns None - use end_drag() instead)
    /// DEPRECATED: Use `end_drag()` instead
    pub fn end_window_drag(&mut self) -> Option<DragContext> {
        if self.active_drag.as_ref().is_some_and(|d| d.is_window_move()) {
            self.end_drag()
        } else {
            None
        }
    }
    /// End file drop (returns None - use end_drag() instead)
    /// DEPRECATED: Use `end_drag()` instead
    pub fn end_file_drop(&mut self) -> Option<DragContext> {
        if self.active_drag.as_ref().is_some_and(|d| d.is_file_drop()) {
            self.end_drag()
        } else {
            None
        }
    }
    /// Cancel file drop
    /// DEPRECATED: Use `cancel_drag()` instead
    pub fn cancel_file_drop(&mut self) {
        if self.active_drag.as_ref().is_some_and(|d| d.is_file_drop()) {
            self.cancel_drag();
        }
    }
    // ========================================================================
    // WINDOW DRAG HELPER METHODS
    // ========================================================================
    /// Calculate window position delta from current drag state
    ///
    /// Returns (delta_x, delta_y) to apply to window position.
    /// Returns None if no window drag is active or drag hasn't moved.
    pub fn get_window_drag_delta(&self) -> Option<(i32, i32)> {
        let drag = self.active_drag.as_ref()?.as_window_move()?;
        let delta_x = drag.current_position.x - drag.start_position.x;
        let delta_y = drag.current_position.y - drag.start_position.y;
        match drag.initial_window_position {
            WindowPosition::Initialized(_initial_pos) => Some((delta_x as i32, delta_y as i32)),
            _ => None,
        }
    }
    /// Get the new window position based on current drag
    ///
    /// Returns the absolute window position to set.
    pub fn get_window_position_from_drag(&self) -> Option<WindowPosition> {
        let drag = self.active_drag.as_ref()?.as_window_move()?;
        let delta_x = drag.current_position.x - drag.start_position.x;
        let delta_y = drag.current_position.y - drag.start_position.y;
        match drag.initial_window_position {
            WindowPosition::Initialized(initial_pos) => {
                Some(WindowPosition::Initialized(PhysicalPositionI32::new(
                    initial_pos.x + delta_x as i32,
                    initial_pos.y + delta_y as i32,
                )))
            }
            _ => None,
        }
    }
    /// Calculate the new scroll offset for scrollbar thumb drag
    pub fn get_scrollbar_scroll_offset(&self) -> Option<f32> {
        self.active_drag.as_ref()?.calculate_scrollbar_scroll_offset()
    }
    /// Remap NodeIds in active drag context after DOM reconciliation.
    ///
    /// When the DOM is regenerated during an active drag, NodeIds can change.
    /// If a critical NodeId was removed, the drag is cancelled.
    pub fn remap_node_ids(
        &mut self,
        dom_id: azul_core::dom::DomId,
        node_id_map: &std::collections::BTreeMap<azul_core::id::NodeId, azul_core::id::NodeId>,
    ) {
        if let Some(ref mut drag) = self.active_drag {
            if !drag.remap_node_ids(dom_id, node_id_map) {
                // Critical node removed — cancel the drag
                drag.cancelled = true;
                self.active_drag = None;
            }
        }
    }
}