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::vec::Vec;
17
#[cfg(feature = "std")]
18
use std::sync::atomic::{AtomicU64, Ordering};
19

            
20
use azul_core::{
21
    dom::{DomId, NodeId},
22
    drag::{ActiveDragType, DragContext, DragData},
23
    geom::{LogicalPosition, PhysicalPositionI32},
24
    hit_test::HitTest,
25
    task::{Duration as CoreDuration, Instant as CoreInstant},
26
    window::WindowPosition,
27
};
28
use azul_css::{impl_option, impl_option_inner};
29

            
30

            
31
#[cfg(feature = "std")]
32
static NEXT_EVENT_ID: AtomicU64 = AtomicU64::new(1);
33

            
34
/// Allocate a new unique event ID
35
#[cfg(feature = "std")]
36
2487
pub fn allocate_event_id() -> u64 {
37
2487
    NEXT_EVENT_ID.fetch_add(1, Ordering::Relaxed)
38
2487
}
39

            
40
/// Allocate a new unique event ID (no_std fallback: returns 0)
41
#[cfg(not(feature = "std"))]
42
pub fn allocate_event_id() -> u64 {
43
    0
44
}
45

            
46
/// Helper function to convert `CoreDuration` to milliseconds
47
///
48
/// `CoreDuration` is an enum with System (`std::time::Duration`) and Tick variants.
49
/// Both go through `CoreDuration::as_millis_u64`, which converts ticks at the
50
/// nominal frame rate.
51
///
52
/// The Tick arm used to return the raw tick count and carried a WARNING that it
53
/// "assumes 1 tick = 1 ms ... will silently produce wrong timing on platforms
54
/// with a different tick resolution". A tick is a FRAME, so that assumption was
55
/// wrong by a factor of ~16 on every platform, and gesture thresholds
56
/// (double-click, long-press) read the result as milliseconds.
57
57
const fn duration_to_millis(duration: CoreDuration) -> u64 {
58
57
    duration.as_millis_u64()
59
57
}
60

            
61
/// Maximum number of input samples to keep in memory
62
///
63
/// This prevents unbounded memory growth during long drags.
64
/// Older samples beyond this limit are automatically discarded.
65
pub const MAX_SAMPLES_PER_SESSION: usize = 1000;
66

            
67
/// Default timeout for clearing old gesture samples (milliseconds)
68
///
69
/// Samples older than this are automatically removed to prevent
70
/// memory leaks and stale gesture detection.
71
pub const DEFAULT_SAMPLE_TIMEOUT_MS: u64 = 2000;
72

            
73
/// Number of samples to drain at once when the session exceeds `MAX_SAMPLES_PER_SESSION`.
74
///
75
/// Batch draining avoids per-sample overhead on every new sample.
76
const DRAIN_BATCH_SIZE: usize = 100;
77

            
78
/// MWA-B4: button-state bitfield recorded for touch-contact samples.
79
///
80
/// A finger on the surface = primary contact, mirroring `BUTTON_STATE_LEFT` in
81
/// the dll's mouse path so drag heuristics treat touch like a held button.
82
pub const TOUCH_CONTACT_BUTTON_STATE: u8 = 0x01;
83

            
84
/// Configuration for gesture detection thresholds
85
#[derive(Debug, Clone, Copy, PartialEq)]
86
pub struct GestureDetectionConfig {
87
    /// Minimum distance (pixels) to consider movement a drag, not a click
88
    pub drag_distance_threshold: f32,
89
    /// Maximum time between clicks for double-click detection (milliseconds)
90
    pub double_click_time_threshold_ms: u64,
91
    /// Maximum distance between clicks for double-click detection (pixels)
92
    pub double_click_distance_threshold: f32,
93
    /// Minimum time to hold button for long-press detection (milliseconds)
94
    pub long_press_time_threshold_ms: u64,
95
    /// Maximum distance to move while holding for long-press (pixels)
96
    pub long_press_distance_threshold: f32,
97
    /// Minimum samples needed to detect a gesture
98
    pub min_samples_for_gesture: usize,
99
    /// Minimum velocity for swipe detection (pixels per second)
100
    pub swipe_velocity_threshold: f32,
101
    /// Minimum scale change for pinch detection (e.g., 0.1 = 10% change)
102
    pub pinch_scale_threshold: f32,
103
    /// Minimum rotation angle for rotation detection (radians)
104
    pub rotation_angle_threshold: f32,
105
    /// How often to clear old samples (milliseconds)
106
    pub sample_cleanup_interval_ms: u64,
107
}
108

            
109
impl Default for GestureDetectionConfig {
110
5700
    fn default() -> Self {
111
5700
        Self {
112
5700
            drag_distance_threshold: 5.0,
113
5700
            double_click_time_threshold_ms: 500,
114
5700
            double_click_distance_threshold: 5.0,
115
5700
            long_press_time_threshold_ms: 500,
116
5700
            long_press_distance_threshold: 10.0,
117
5700
            min_samples_for_gesture: 2,
118
5700
            swipe_velocity_threshold: 500.0, // 500 px/s
119
5700
            pinch_scale_threshold: 0.1,      // 10% scale change
120
5700
            rotation_angle_threshold: 0.1,   // ~5.7 degrees in radians
121
5700
            sample_cleanup_interval_ms: DEFAULT_SAMPLE_TIMEOUT_MS,
122
5700
        }
123
5700
    }
124
}
125

            
126
/// Single input sample with position and timestamp
127
#[derive(Debug, Clone, PartialEq)]
128
pub struct InputSample {
129
    /// Position in logical coordinates (window-local, Y=0 at top of window)
130
    pub position: LogicalPosition,
131
    /// Position in virtual screen coordinates (Y=0 at top of primary monitor).
132
    ///
133
    /// Computed as `window_position + position` at the time the sample is recorded.
134
    /// This is stable during window drags because `window_pos + cursor_local`
135
    /// always equals the true screen position, even when the window moves.
136
    ///
137
    /// All coordinates are in logical pixels (HiDPI-independent).
138
    /// On Wayland, this is an estimate (compositor does not expose global position).
139
    pub screen_position: LogicalPosition,
140
    /// Timestamp when this sample was recorded (from `ExternalSystemCallbacks`)
141
    pub timestamp: CoreInstant,
142
    /// Mouse button state (bitfield: 0x01 = left, 0x02 = right, 0x04 = middle)
143
    pub button_state: u8,
144
    /// Unique, monotonic event ID for ordering (atomic counter)
145
    pub event_id: u64,
146
    /// Pen/stylus pressure (0.0 to 1.0, 0.5 = default for mouse)
147
    pub pressure: f32,
148
    /// Pen/stylus tilt angles in degrees (`x_tilt`, `y_tilt`)
149
    /// Range: typically -90.0 to 90.0, (0.0, 0.0) = perpendicular
150
    pub tilt: (f32, f32),
151
    /// Touch contact radius in logical pixels (width, height)
152
    /// For mouse input, this is (0.0, 0.0)
153
    pub touch_radius: (f32, f32),
154
}
155

            
156
impl_option!(
157
    InputSample,
158
    OptionInputSample,
159
    copy = false,
160
    [Debug, Clone, PartialEq]
161
);
162

            
163
/// A sequence of input samples forming one button press session
164
#[derive(Debug, Clone, PartialEq)]
165
pub struct InputSession {
166
    /// All recorded samples for this session
167
    pub samples: Vec<InputSample>,
168
    /// Whether this session has ended (button released)
169
    pub ended: bool,
170
    /// Session ID for tracking (incremental counter)
171
    pub session_id: u64,
172
    /// Window position at the time this session started (mouse-down).
173
    /// Used by titlebar drag callbacks to compute new window position.
174
    pub window_position_at_start: WindowPosition,
175
}
176

            
177
impl InputSession {
178
    /// Create a new input session
179
85
    fn new(session_id: u64, first_sample: InputSample, window_position: WindowPosition) -> Self {
180
85
        Self {
181
85
            samples: vec![first_sample],
182
85
            ended: false,
183
85
            session_id,
184
85
            window_position_at_start: window_position,
185
85
        }
186
85
    }
187

            
188
    /// Get the first sample in this session
189
124
    #[must_use] pub fn first_sample(&self) -> Option<&InputSample> {
190
124
        self.samples.first()
191
124
    }
192

            
193
    /// Get the last sample in this session
194
98
    #[must_use] pub fn last_sample(&self) -> Option<&InputSample> {
195
98
        self.samples.last()
196
98
    }
197

            
198
    /// Get the duration of this session (first to last sample)
199
28
    #[must_use] pub fn duration_ms(&self) -> Option<u64> {
200
28
        let first = self.first_sample()?;
201
27
        let last = self.last_sample()?;
202
27
        let duration = last.timestamp.duration_since(&first.timestamp);
203
27
        Some(duration_to_millis(duration))
204
28
    }
205

            
206
    /// Get the total distance traveled in this session
207
23
    #[must_use] pub fn total_distance(&self) -> f32 {
208
23
        if self.samples.len() < 2 {
209
2
            return 0.0;
210
21
        }
211

            
212
21
        let mut total = 0.0;
213
22
        for i in 1..self.samples.len() {
214
22
            let prev = &self.samples[i - 1];
215
22
            let curr = &self.samples[i];
216
22
            let dx = curr.position.x - prev.position.x;
217
22
            let dy = curr.position.y - prev.position.y;
218
22
            total += dx.hypot(dy);
219
22
        }
220
21
        total
221
23
    }
222

            
223
    /// Get the straight-line distance from first to last sample
224
21
    #[must_use] pub fn direct_distance(&self) -> Option<f32> {
225
21
        let first = self.first_sample()?;
226
20
        let last = self.last_sample()?;
227
20
        let dx = last.position.x - first.position.x;
228
20
        let dy = last.position.y - first.position.y;
229
20
        Some(dx.hypot(dy))
230
21
    }
231
}
232

            
233
/// Result of drag detection analysis
234
#[derive(Debug, Clone, Copy, PartialEq)]
235
pub struct DetectedDrag {
236
    /// Position where drag started
237
    pub start_position: LogicalPosition,
238
    /// Current/end position of drag
239
    pub current_position: LogicalPosition,
240
    /// Direct distance dragged (straight line, pixels)
241
    pub direct_distance: f32,
242
    /// Total distance dragged (following path, pixels)
243
    pub total_distance: f32,
244
    /// Duration of the drag (milliseconds)
245
    pub duration_ms: u64,
246
    /// Number of position samples recorded
247
    pub sample_count: usize,
248
    /// Session ID this drag belongs to
249
    pub session_id: u64,
250
}
251

            
252
/// Result of long-press detection
253
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254
#[repr(C)]
255
pub struct DetectedLongPress {
256
    /// Position where long press is happening
257
    pub position: LogicalPosition,
258
    /// How long the button has been held (milliseconds)
259
    pub duration_ms: u64,
260
    /// Whether the callback has already been invoked for this long press
261
    pub callback_invoked: bool,
262
    /// Session ID this long press belongs to
263
    pub session_id: u64,
264
}
265

            
266
/// Primary direction of a gesture
267
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268
#[repr(C)]
269
pub enum GestureDirection {
270
    Up,
271
    Down,
272
    Left,
273
    Right,
274
}
275

            
276
impl_option!(
277
    GestureDirection,
278
    OptionGestureDirection,
279
    [Debug, Clone, Copy, PartialEq, Eq]
280
);
281
impl_option!(
282
    DetectedPinch,
283
    OptionDetectedPinch,
284
    [Debug, Clone, Copy, PartialEq]
285
);
286
impl_option!(
287
    DetectedRotation,
288
    OptionDetectedRotation,
289
    [Debug, Clone, Copy, PartialEq]
290
);
291
impl_option!(
292
    DetectedLongPress,
293
    OptionDetectedLongPress,
294
    [Debug, Clone, Copy, PartialEq, Eq]
295
);
296

            
297
/// Result of pinch gesture detection
298
#[derive(Debug, Clone, Copy, PartialEq)]
299
#[repr(C)]
300
pub struct DetectedPinch {
301
    /// Scale factor (< 1.0 for pinch in, > 1.0 for pinch out)
302
    pub scale: f32,
303
    /// Center point of the pinch gesture
304
    pub center: LogicalPosition,
305
    /// Initial distance between touch points
306
    pub initial_distance: f32,
307
    /// Current distance between touch points
308
    pub current_distance: f32,
309
    /// Duration of pinch (milliseconds)
310
    pub duration_ms: u64,
311
}
312

            
313
/// Result of rotation gesture detection
314
#[derive(Debug, Clone, Copy, PartialEq)]
315
#[repr(C)]
316
pub struct DetectedRotation {
317
    /// Rotation angle in radians (positive = clockwise)
318
    pub angle_radians: f32,
319
    /// Center point of rotation
320
    pub center: LogicalPosition,
321
    /// Duration of rotation (milliseconds)
322
    pub duration_ms: u64,
323
}
324

            
325

            
326
/// State of pen/stylus input
327
#[derive(Debug, Clone, Copy, PartialEq)]
328
#[repr(C)]
329
pub struct PenState {
330
    /// Current pen position
331
    pub position: LogicalPosition,
332
    /// Current pressure (0.0 to 1.0)
333
    pub pressure: f32,
334
    /// Current tilt angles (`x_tilt`, `y_tilt`) in degrees
335
    pub tilt: crate::callbacks::PenTilt,
336
    /// Whether pen is in contact with surface
337
    pub in_contact: bool,
338
    /// Whether pen is inverted (eraser mode)
339
    pub is_eraser: bool,
340
    /// Whether barrel button is pressed
341
    pub barrel_button_pressed: bool,
342
    /// Unique identifier for this pen device
343
    pub device_id: u64,
344
    /// Tangential / cylinder pressure (0.0 to 1.0). Wacom Air Brush wheel,
345
    /// Surface Slim Pen 2 secondary axis. `0.0` means "not reported".
346
    /// Maps to W3C `PointerEvent.tangentialPressure`.
347
    pub tangential_pressure: f32,
348
    /// Barrel roll angle in radians (–π to π). Wacom Art Pen rotation,
349
    /// Surface Pen barrel-roll axis. `0.0` means "not reported" (devices
350
    /// that do report it sweep through the full range as the user rolls
351
    /// the pen — the resting state isn't necessarily zero, so callers
352
    /// should compare deltas, not absolute values).
353
    /// Maps to W3C `PointerEvent.twist` (in radians, not degrees).
354
    pub barrel_roll_rad: f32,
355
    /// Per-tool identity for hand-held pens that report it (Wintab GUID,
356
    /// Apple Pencil session id, S-Pen serial). `0` means "not reported".
357
    /// Distinct from `device_id` so callers can both identify the
358
    /// hardware (`device_id`) *and* which tip / lead / button cluster is
359
    /// in use (`tool_id`).
360
    pub tool_id: u32,
361
}
362

            
363
impl_option!(PenState, OptionPenState, [Debug, Clone, Copy, PartialEq]);
364

            
365
impl Default for PenState {
366
    fn default() -> Self {
367
        Self {
368
            position: LogicalPosition::zero(),
369
            pressure: 0.0,
370
            tilt: crate::callbacks::PenTilt {
371
                x_tilt: 0.0,
372
                y_tilt: 0.0,
373
            },
374
            in_contact: false,
375
            is_eraser: false,
376
            barrel_button_pressed: false,
377
            device_id: 0,
378
            tangential_pressure: 0.0,
379
            barrel_roll_rad: 0.0,
380
            tool_id: 0,
381
        }
382
    }
383
}
384

            
385
/// State of a Wacom-style tablet **pad** — the tablet body's own hardware
386
/// controls, distinct from the pen ([`PenState`] already covers eraser /
387
/// barrel button / barrel roll / tilt / pressure).
388
///
389
/// Populated by the platform
390
/// backend (`dll/src/desktop/extra/wacom_pad/`: Wintab on Windows,
391
/// libwacom+libinput on Linux, the driver's `NSEvent` tablet events on macOS).
392
#[derive(Debug, Clone, Copy, PartialEq)]
393
#[repr(C)]
394
pub struct WacomPadState {
395
    /// `ExpressKey` bitset — bit `n` set ⇔ hardware button `n` is held (up to
396
    /// 32). Read via [`WacomPadState::express_key`].
397
    pub express_keys: u32,
398
    /// Touch-ring / touch-strip absolute position, `0.0`–`1.0`. Only
399
    /// meaningful while [`WacomPadState::touch_ring_active`] is `true`.
400
    pub touch_ring: f32,
401
    /// Whether a finger is currently on the touch-ring / touch-strip.
402
    pub touch_ring_active: bool,
403
    /// Tablet device id (to distinguish pads on multi-tablet setups).
404
    pub device_id: u64,
405
}
406

            
407
impl_option!(
408
    WacomPadState,
409
    OptionWacomPadState,
410
    [Debug, Clone, Copy, PartialEq]
411
);
412

            
413
impl Default for WacomPadState {
414
1
    fn default() -> Self {
415
1
        Self {
416
1
            express_keys: 0,
417
1
            touch_ring: 0.0,
418
1
            touch_ring_active: false,
419
1
            device_id: 0,
420
1
        }
421
1
    }
422
}
423

            
424
impl WacomPadState {
425
    /// Whether `ExpressKey` `index` (0-based, < 32) is currently held.
426
1072
    #[must_use] pub const fn express_key(&self, index: u32) -> bool {
427
1072
        index < 32 && (self.express_keys & (1u32 << index)) != 0
428
1072
    }
429
}
430

            
431
/// Manager for multi-frame gestures and drag operations
432
///
433
/// This collects raw input samples and analyzes them to detect gestures.
434
/// Designed for testability and clear separation of input collection
435
/// vs. detection.
436
///
437
/// ## Unified Drag System
438
///
439
/// The manager now uses `DragContext` to unify all drag types:
440
/// - `active_drag`: The unified drag context (replaces individual drag states)
441
///
442
/// For backwards compatibility, the old `node_drag`, `window_drag`, `file_drop`
443
/// fields are still accessible but deprecated.
444
#[derive(Debug, Clone, PartialEq)]
445
pub struct GestureAndDragManager {
446
    /// Configuration for gesture detection
447
    pub config: GestureDetectionConfig,
448
    /// All recorded input sessions (multiple button press sequences)
449
    pub input_sessions: Vec<InputSession>,
450
    /// **NEW**: Unified drag context for all drag types
451
    pub active_drag: Option<DragContext>,
452
    /// Current pen/stylus state
453
    pub pen_state: Option<PenState>,
454
    /// Pen state as of the previous determine-events pass (for diffing pen events).
455
    pub previous_pen_state: Option<PenState>,
456
    /// Set when pen state changed; gates one pen-event diff (cleared by the event loop).
457
    pub pen_event_pending: bool,
458
    /// Latest Wacom tablet-pad state (`ExpressKeys` + touch-ring), or `None`
459
    /// until a pad backend delivers one.
460
    pub pad_state: Option<WacomPadState>,
461
    /// Session IDs where long press callback has been invoked
462
    long_press_callbacks_invoked: Vec<u64>,
463
    /// Counter for generating unique session IDs
464
    next_session_id: u64,
465
    /// Native-platform gesture override slot.
466
    ///
467
    /// Platforms with first-class gesture recognizers (iOS `UIKit`,
468
    /// Android `GestureDetector` + `ScaleGestureDetector`, macOS
469
    /// `NSGestureRecognizer`) inject pre-detected gestures here via
470
    /// [`GestureAndDragManager::inject_native_gesture`]. The
471
    /// `detect_*` methods consult this slot before running their
472
    /// in-process heuristics, so callbacks observe consistent results
473
    /// regardless of the detection source.
474
    ///
475
    /// Cleared automatically at the start of every new input recording
476
    /// cycle so a single OS event doesn't keep firing.
477
    pub native_gesture: Option<NativeGestureEvent>,
478
    /// MWA-B4: OS touch id → session id. Desktop touch events previously
479
    /// only filled the window's `touch_state`, so no touch ever became an
480
    /// input session and `detect_pinch` / `detect_rotation` (which need two
481
    /// concurrent sessions) were structurally dead on Windows/X11/Wayland.
482
    /// The shells call [`touch_down`](Self::touch_down) /
483
    /// [`touch_move`](Self::touch_move) / [`touch_up`](Self::touch_up); each
484
    /// finger gets its own session (two fingers = two live sessions).
485
    touch_sessions: alloc::collections::btree_map::BTreeMap<u64, u64>,
486
}
487

            
488
/// Gesture detected by a platform-native recognizer.
489
///
490
/// Platform backends construct one of these in their gesture-recognizer
491
/// callbacks (iOS `UIKit`, Android `GestureDetector`, macOS
492
/// `NSGestureRecognizer`) and hand it to
493
/// [`GestureAndDragManager::inject_native_gesture`]. The in-process
494
/// `detect_*` methods then return the native result, sidestepping their
495
/// fallback heuristics. On platforms with poor native gesture support
496
/// (X11 / Wayland touch, headless), backends never inject and the
497
/// in-process detector remains authoritative.
498
#[derive(Debug, Clone, Copy, PartialEq)]
499
#[repr(C, u8)]
500
pub enum NativeGestureEvent {
501
    /// Single tap / double-click detected natively.
502
    DoubleClick,
503
    /// Long-press detected natively (iOS `UILongPressGestureRecognizer`,
504
    /// Android `GestureDetector.OnGestureListener::onLongPress`).
505
    LongPress(DetectedLongPress),
506
    /// Swipe detected natively (iOS `UISwipeGestureRecognizer`,
507
    /// Android `GestureDetector.OnGestureListener::onFling`).
508
    Swipe(GestureDirection),
509
    /// Pinch detected natively (iOS `UIPinchGestureRecognizer`,
510
    /// Android `ScaleGestureDetector`, macOS magnification gesture).
511
    Pinch(DetectedPinch),
512
    /// Rotation detected natively (iOS `UIRotationGestureRecognizer`,
513
    /// macOS rotation gesture).
514
    Rotation(DetectedRotation),
515
}
516

            
517

            
518
impl Default for GestureAndDragManager {
519
1
    fn default() -> Self {
520
1
        Self::new()
521
1
    }
522
}
523

            
524
impl GestureAndDragManager {
525
    /// (`input_sessions`, `long_press_callbacks_invoked`). Used by
526
    /// `AZ_E2E_TEST` to watch for unbounded growth.
527
7
    #[must_use] pub const fn debug_counts(&self) -> (usize, usize) {
528
7
        (self.input_sessions.len(), self.long_press_callbacks_invoked.len())
529
7
    }
530

            
531
    /// Create a new gesture and drag manager
532
5700
    #[must_use] pub fn new() -> Self {
533
5700
        Self {
534
5700
            config: GestureDetectionConfig::default(),
535
5700
            input_sessions: Vec::new(),
536
5700
            next_session_id: 1,
537
5700
            active_drag: None,
538
5700
            pen_state: None,
539
5700
            previous_pen_state: None,
540
5700
            pen_event_pending: false,
541
5700
            pad_state: None,
542
5700
            long_press_callbacks_invoked: Vec::new(),
543
5700
            native_gesture: None,
544
5700
            touch_sessions: alloc::collections::btree_map::BTreeMap::new(),
545
5700
        }
546
5700
    }
547

            
548
    /// Inject a native gesture-recognizer result, overriding the
549
    /// in-process detector for the current event frame. Called by the
550
    /// iOS / Android / macOS platform backend from their gesture
551
    /// recognizer callbacks. The override is read once by the next
552
    /// `detect_*` call.
553
27
    pub const fn inject_native_gesture(&mut self, gesture: NativeGestureEvent) {
554
27
        self.native_gesture = Some(gesture);
555
27
    }
556

            
557
    /// Clear any pending native-gesture override. Called by the event
558
    /// loop after each frame's detections have been consumed so a
559
    /// stale OS gesture doesn't keep firing.
560
76
    pub const fn clear_native_gesture(&mut self) {
561
76
        self.native_gesture = None;
562
76
    }
563

            
564
    /// Create with custom configuration
565
1
    #[must_use] pub fn with_config(config: GestureDetectionConfig) -> Self {
566
1
        Self {
567
1
            config,
568
1
            ..Self::new()
569
1
        }
570
1
    }
571

            
572
    // Input Recording Methods (called from event loop / system timer)
573

            
574
    /// Start a new input session (mouse button pressed down)
575
    ///
576
    /// This begins recording samples for gesture detection.
577
    /// Call this when receiving mouse button down event.
578
    ///
579
    /// `window_position` is the current OS window position at the time of mouse-down.
580
    /// It is stored so that drag callbacks can compute the new window position.
581
    ///
582
    /// Returns the session ID for this new session.
583
83
    pub fn start_input_session(
584
83
        &mut self,
585
83
        position: LogicalPosition,
586
83
        timestamp: CoreInstant,
587
83
        button_state: u8,
588
83
        window_position: WindowPosition,
589
83
        screen_position: LogicalPosition,
590
83
    ) -> u64 {
591
83
        self.start_input_session_with_pen(
592
83
            position,
593
83
            timestamp,
594
83
            button_state,
595
83
            allocate_event_id(),
596
            0.5,        // default pressure for mouse
597
83
            (0.0, 0.0), // no tilt for mouse
598
83
            (0.0, 0.0), // no touch radius for mouse
599
83
            window_position,
600
83
            screen_position,
601
        )
602
83
    }
603

            
604
    /// Start a new input session with pen/touch data
605
84
    pub fn start_input_session_with_pen(
606
84
        &mut self,
607
84
        position: LogicalPosition,
608
84
        timestamp: CoreInstant,
609
84
        button_state: u8,
610
84
        event_id: u64,
611
84
        pressure: f32,
612
84
        tilt: (f32, f32),
613
84
        touch_radius: (f32, f32),
614
84
        window_position: WindowPosition,
615
84
        screen_position: LogicalPosition,
616
84
    ) -> u64 {
617
        // Clear old ended sessions, but keep the most recent ended session
618
        // for double-click detection. detect_double_click() needs two ended
619
        // sessions to compare timing and distance.
620
84
        let last_ended_idx = self.input_sessions.iter().rposition(|s| s.ended);
621
84
        let mut idx = 0usize;
622
84
        self.input_sessions.retain(|session| {
623
29
            let keep = !session.ended || Some(idx) == last_ended_idx;
624
29
            idx += 1;
625
29
            keep
626
29
        });
627

            
628
84
        let session_id = self.next_session_id;
629
84
        self.next_session_id += 1;
630

            
631
84
        let sample = InputSample {
632
84
            position,
633
84
            screen_position,
634
84
            timestamp,
635
84
            button_state,
636
84
            event_id,
637
84
            pressure,
638
84
            tilt,
639
84
            touch_radius,
640
84
        };
641

            
642
84
        let session = InputSession::new(session_id, sample, window_position);
643
84
        self.input_sessions.push(session);
644

            
645
84
        session_id
646
84
    }
647

            
648
    /// Record an input sample to the current session
649
    ///
650
    /// Call this on every mouse move event while button is pressed,
651
    /// and also periodically from a system timer to track long presses.
652
    ///
653
    /// Returns true if sample was recorded, false if no active session.
654
1233
    pub fn record_input_sample(
655
1233
        &mut self,
656
1233
        position: LogicalPosition,
657
1233
        timestamp: CoreInstant,
658
1233
        button_state: u8,
659
1233
        screen_position: LogicalPosition,
660
1233
    ) -> bool {
661
1233
        self.record_input_sample_with_pen(
662
1233
            position,
663
1233
            timestamp,
664
1233
            button_state,
665
1233
            allocate_event_id(),
666
            0.5,        // default pressure for mouse
667
1233
            (0.0, 0.0), // no tilt for mouse
668
1233
            (0.0, 0.0), // no touch radius for mouse
669
1233
            screen_position,
670
        )
671
1233
    }
672

            
673
    /// Record an input sample with pen/touch data
674
1234
    pub fn record_input_sample_with_pen(
675
1234
        &mut self,
676
1234
        position: LogicalPosition,
677
1234
        timestamp: CoreInstant,
678
1234
        button_state: u8,
679
1234
        event_id: u64,
680
1234
        pressure: f32,
681
1234
        tilt: (f32, f32),
682
1234
        touch_radius: (f32, f32),
683
1234
        screen_position: LogicalPosition,
684
1234
    ) -> bool {
685
1234
        let Some(session) = self.input_sessions.last_mut() else {
686
1
            return false;
687
        };
688

            
689
1233
        if session.ended {
690
1
            return false;
691
1232
        }
692

            
693
        // Enforce max samples limit
694
1232
        if session.samples.len() >= MAX_SAMPLES_PER_SESSION {
695
3
            // Remove oldest samples, keeping the most recent ones
696
3
            let remove_count = session.samples.len() - MAX_SAMPLES_PER_SESSION + DRAIN_BATCH_SIZE;
697
3
            session.samples.drain(0..remove_count);
698
1229
        }
699

            
700
1232
        session.samples.push(InputSample {
701
1232
            position,
702
1232
            screen_position,
703
1232
            timestamp,
704
1232
            button_state,
705
1232
            event_id,
706
1232
            pressure,
707
1232
            tilt,
708
1232
            touch_radius,
709
1232
        });
710

            
711
1232
        true
712
1234
    }
713

            
714
    /// End the current input session (mouse button released)
715
    ///
716
    /// Call this when receiving mouse button up event.
717
    /// The session is kept for analysis but marked as ended.
718
15
    pub fn end_current_session(&mut self) {
719
15
        if let Some(session) = self.input_sessions.last_mut() {
720
14
            session.ended = true;
721
14
        }
722
15
    }
723

            
724
    // --- Per-touch-id input sessions (MWA-B4) ---
725

            
726
    /// A finger made contact: open a dedicated session for `touch_id`.
727
33
    pub fn touch_down(
728
33
        &mut self,
729
33
        touch_id: u64,
730
33
        position: LogicalPosition,
731
33
        timestamp: CoreInstant,
732
33
        window_position: WindowPosition,
733
33
        screen_position: LogicalPosition,
734
33
    ) {
735
33
        let session_id = self.start_input_session(
736
33
            position,
737
33
            timestamp,
738
            TOUCH_CONTACT_BUTTON_STATE,
739
33
            window_position,
740
33
            screen_position,
741
        );
742
33
        self.touch_sessions.insert(touch_id, session_id);
743
33
    }
744

            
745
    /// A finger moved: record into ITS OWN session — never `last_mut()`,
746
    /// two concurrent fingers must not interleave into one session (that
747
    /// would corrupt both the drag heuristics and pinch/rotate distances).
748
    /// Returns `true` if a sample was recorded.
749
1168
    pub fn touch_move(
750
1168
        &mut self,
751
1168
        touch_id: u64,
752
1168
        position: LogicalPosition,
753
1168
        timestamp: CoreInstant,
754
1168
        screen_position: LogicalPosition,
755
1168
    ) -> bool {
756
1168
        let Some(session_id) = self.touch_sessions.get(&touch_id).copied() else {
757
3
            return false;
758
        };
759
1165
        self.record_sample_for_session(session_id, position, timestamp, screen_position)
760
1168
    }
761

            
762
    /// A finger lifted (or the OS cancelled the touch): final sample + end
763
    /// the session and drop the id mapping.
764
4
    pub fn touch_up(
765
4
        &mut self,
766
4
        touch_id: u64,
767
4
        position: LogicalPosition,
768
4
        timestamp: CoreInstant,
769
4
        screen_position: LogicalPosition,
770
4
    ) {
771
4
        let Some(session_id) = self.touch_sessions.remove(&touch_id) else {
772
1
            return;
773
        };
774
3
        let _ = self.record_sample_for_session(session_id, position, timestamp, screen_position);
775
3
        if let Some(session) = self
776
3
            .input_sessions
777
3
            .iter_mut()
778
4
            .find(|s| s.session_id == session_id)
779
3
        {
780
3
            session.ended = true;
781
3
        }
782
4
    }
783

            
784
    /// The OS cancelled the whole touch sequence (e.g. the compositor took
785
    /// the gesture over): end every touch session and drop the id map.
786
2
    pub fn touch_cancel_all(&mut self) {
787
2
        let ids: Vec<u64> = self.touch_sessions.values().copied().collect();
788
2
        self.touch_sessions.clear();
789
5
        for session_id in ids {
790
3
            if let Some(session) = self
791
3
                .input_sessions
792
3
                .iter_mut()
793
6
                .find(|s| s.session_id == session_id)
794
3
            {
795
3
                session.ended = true;
796
3
            }
797
        }
798
2
    }
799

            
800
    /// Record a sample into the session with `session_id` (MWA-B4 helper —
801
    /// the by-id sibling of `record_input_sample_with_pen`, which only ever
802
    /// writes to the LAST session).
803
1172
    fn record_sample_for_session(
804
1172
        &mut self,
805
1172
        session_id: u64,
806
1172
        position: LogicalPosition,
807
1172
        timestamp: CoreInstant,
808
1172
        screen_position: LogicalPosition,
809
1172
    ) -> bool {
810
1172
        let Some(session) = self
811
1172
            .input_sessions
812
1172
            .iter_mut()
813
1179
            .find(|s| s.session_id == session_id)
814
        else {
815
3
            return false;
816
        };
817
1169
        if session.ended {
818
1
            return false;
819
1168
        }
820
1168
        if session.samples.len() >= MAX_SAMPLES_PER_SESSION {
821
2
            let remove_count =
822
2
                session.samples.len() - MAX_SAMPLES_PER_SESSION + DRAIN_BATCH_SIZE;
823
2
            session.samples.drain(0..remove_count);
824
1166
        }
825
1168
        session.samples.push(InputSample {
826
1168
            position,
827
1168
            screen_position,
828
1168
            timestamp,
829
1168
            button_state: TOUCH_CONTACT_BUTTON_STATE,
830
1168
            event_id: allocate_event_id(),
831
1168
            pressure: 0.5,
832
1168
            tilt: (0.0, 0.0),
833
1168
            touch_radius: (0.0, 0.0),
834
1168
        });
835
1168
        true
836
1172
    }
837

            
838
    /// Clear old input sessions that have timed out
839
    ///
840
    /// Call this periodically (e.g., every frame) to prevent memory leaks.
841
    /// Sessions older than `config.sample_cleanup_interval_ms` are removed.
842
    // CoreInstant is a ref-counted FFI clock handle threaded through the event loop by value;
843
    // &-converting would cascade through the loop call chain and across all dll backends.
844
    #[allow(clippy::needless_pass_by_value)]
845
3
    pub fn clear_old_sessions(&mut self, current_time: CoreInstant) {
846
4
        self.input_sessions.retain(|session| {
847
4
            if let Some(last_sample) = session.last_sample() {
848
3
                let duration = current_time.duration_since(&last_sample.timestamp);
849
3
                let age_ms = duration_to_millis(duration);
850
3
                age_ms < self.config.sample_cleanup_interval_ms
851
            } else {
852
1
                false
853
            }
854
4
        });
855

            
856
        // Also clear long press callback tracking for removed sessions
857
3
        let valid_session_ids: Vec<u64> =
858
3
            self.input_sessions.iter().map(|s| s.session_id).collect();
859

            
860
3
        self.long_press_callbacks_invoked
861
3
            .retain(|id| valid_session_ids.contains(id));
862
3
    }
863

            
864
    /// Clear all input sessions
865
    ///
866
    /// Call this when you want to reset all gesture detection state.
867
3
    pub fn clear_all_sessions(&mut self) {
868
3
        self.input_sessions.clear();
869
3
        self.long_press_callbacks_invoked.clear();
870
3
    }
871

            
872
    /// Update pen/stylus state
873
    ///
874
    /// Call this when receiving pen events from the platform. The
875
    /// extended fields (`tangential_pressure`, `barrel_roll_rad`,
876
    /// `tool_id`) default to `0` — pass [`update_pen_state_full`] when
877
    /// the platform reports them.
878
13
    pub const fn update_pen_state(
879
13
        &mut self,
880
13
        position: LogicalPosition,
881
13
        pressure: f32,
882
13
        tilt: (f32, f32),
883
13
        in_contact: bool,
884
13
        is_eraser: bool,
885
13
        barrel_button_pressed: bool,
886
13
        device_id: u64,
887
13
    ) {
888
13
        self.update_pen_state_full(
889
13
            position,
890
13
            pressure,
891
13
            tilt,
892
13
            in_contact,
893
13
            is_eraser,
894
13
            barrel_button_pressed,
895
13
            device_id,
896
            0.0,
897
            0.0,
898
            0,
899
        );
900
13
    }
901

            
902
    /// Update pen/stylus state including the extended axes (W3C
903
    /// `PointerEvent.tangentialPressure` + `twist`) and per-tool id.
904
14
    pub const fn update_pen_state_full(
905
14
        &mut self,
906
14
        position: LogicalPosition,
907
14
        pressure: f32,
908
14
        tilt: (f32, f32),
909
14
        in_contact: bool,
910
14
        is_eraser: bool,
911
14
        barrel_button_pressed: bool,
912
14
        device_id: u64,
913
14
        tangential_pressure: f32,
914
14
        barrel_roll_rad: f32,
915
14
        tool_id: u32,
916
14
    ) {
917
14
        self.previous_pen_state = self.pen_state;
918
14
        self.pen_state = Some(PenState {
919
14
            position,
920
14
            pressure,
921
14
            tilt: crate::callbacks::PenTilt {
922
14
                x_tilt: tilt.0,
923
14
                y_tilt: tilt.1,
924
14
            },
925
14
            in_contact,
926
14
            is_eraser,
927
14
            barrel_button_pressed,
928
14
            device_id,
929
14
            tangential_pressure,
930
14
            barrel_roll_rad,
931
14
            tool_id,
932
14
        });
933
14
        self.pen_event_pending = true;
934
14
    }
935

            
936
    /// Clear pen state (when pen leaves proximity)
937
3
    pub const fn clear_pen_state(&mut self) {
938
3
        self.previous_pen_state = self.pen_state;
939
3
        self.pen_state = None;
940
3
        self.pen_event_pending = true;
941
3
    }
942

            
943
    /// Get current pen state (read-only)
944
134
    #[must_use] pub const fn get_pen_state(&self) -> Option<&PenState> {
945
134
        self.pen_state.as_ref()
946
134
    }
947

            
948
    /// Get the previous pen state (for event diffing).
949
11
    #[must_use] pub const fn get_previous_pen_state(&self) -> Option<&PenState> {
950
11
        self.previous_pen_state.as_ref()
951
11
    }
952

            
953
    /// Clear the pen-event-pending flag (called by the event loop after a pass).
954
77
    pub const fn clear_pen_event_pending(&mut self) {
955
77
        self.pen_event_pending = false;
956
77
    }
957

            
958
    /// Set the latest Wacom tablet-pad state (called by the pad backend).
959
1
    pub const fn update_pad_state(&mut self, pad: WacomPadState) {
960
1
        self.pad_state = Some(pad);
961
1
    }
962

            
963
    /// The latest tablet-pad state, or `None` if no pad backend delivered one.
964
5
    #[must_use] pub const fn get_pad_state(&self) -> Option<&WacomPadState> {
965
5
        self.pad_state.as_ref()
966
5
    }
967

            
968
    /// Clear the tablet-pad state (pad disconnected / proximity left).
969
2
    pub const fn clear_pad_state(&mut self) {
970
2
        self.pad_state = None;
971
2
    }
972

            
973
    // Gesture Detection Methods (query state without mutation)
974

            
975
    /// Detect if current input represents a drag gesture
976
    ///
977
    /// Returns Some(DetectedDrag) if a drag is detected based on distance threshold.
978
122
    #[must_use] pub fn detect_drag(&self) -> Option<DetectedDrag> {
979
122
        let session = self.get_current_session()?;
980

            
981
14
        if session.samples.len() < self.config.min_samples_for_gesture {
982
2
            return None;
983
12
        }
984

            
985
12
        let direct_distance = session.direct_distance()?;
986

            
987
12
        if direct_distance >= self.config.drag_distance_threshold {
988
8
            let first = session.first_sample()?;
989
8
            let last = session.last_sample()?;
990

            
991
            Some(DetectedDrag {
992
8
                start_position: first.position,
993
8
                current_position: last.position,
994
8
                direct_distance,
995
8
                total_distance: session.total_distance(),
996
8
                duration_ms: session.duration_ms()?,
997
8
                sample_count: session.samples.len(),
998
8
                session_id: session.session_id,
999
            })
        } else {
4
            None
        }
122
    }
    /// Detect if current input represents a long press
    ///
    /// Returns Some(DetectedLongPress) if button has been held long enough
    /// without moving much.
115
    #[must_use] pub fn detect_long_press(&self) -> Option<DetectedLongPress> {
23
        if let Some(NativeGestureEvent::LongPress(lp)) = self.native_gesture {
3
            return Some(lp);
112
        }
112
        let session = self.get_current_session()?;
6
        if session.ended {
1
            return None; // Can't be long press if button already released
5
        }
5
        let duration_ms = session.duration_ms()?;
5
        if duration_ms < self.config.long_press_time_threshold_ms {
1
            return None;
4
        }
4
        let distance = session.direct_distance()?;
4
        if distance <= self.config.long_press_distance_threshold {
3
            let first = session.first_sample()?;
3
            let callback_invoked = self
3
                .long_press_callbacks_invoked
3
                .contains(&session.session_id);
3
            Some(DetectedLongPress {
3
                position: first.position,
3
                duration_ms,
3
                callback_invoked,
3
                session_id: session.session_id,
3
            })
        } else {
1
            None
        }
115
    }
    /// Mark long press callback as invoked for a session
    ///
    /// Call this after invoking the long press callback to prevent
    /// repeated invocations.
    /// MWA-B12: mark the CURRENT session's long-press as delivered. The
    /// event pass calls this right after emitting `EventType::LongPress` —
    /// nothing ever called `mark_long_press_callback_invoked`, so `LongPress`
    /// re-fired on every subsequent pass of the same hold.
3
    pub fn mark_current_long_press_invoked(&mut self) {
3
        if let Some(id) = self.get_current_session().map(|s| s.session_id) {
2
            self.mark_long_press_callback_invoked(id);
2
        }
3
    }
204
    pub fn mark_long_press_callback_invoked(&mut self, session_id: u64) {
204
        if !self.long_press_callbacks_invoked.contains(&session_id) {
6
            self.long_press_callbacks_invoked.push(session_id);
198
        }
204
    }
    /// Detect if last two sessions form a double-click.
    ///
    /// Returns true if timing and distance match double-click criteria.
116
    #[must_use] pub fn detect_double_click(&self) -> bool {
114
        if matches!(self.native_gesture, Some(NativeGestureEvent::DoubleClick)) {
2
            return true;
114
        }
114
        let sessions = &self.input_sessions;
114
        if sessions.len() < 2 {
109
            return false;
5
        }
5
        let prev_session = &sessions[sessions.len() - 2];
5
        let last_session = &sessions[sessions.len() - 1];
        // Both sessions must have ended (button released)
5
        if !prev_session.ended || !last_session.ended {
1
            return false;
4
        }
4
        let prev_first = prev_session.first_sample();
4
        let last_first = last_session.first_sample();
4
        let (Some(prev_first), Some(last_first)) = (prev_first, last_first) else {
1
            return false;
        };
3
        let duration = last_first.timestamp.duration_since(&prev_first.timestamp);
3
        let time_delta_ms = duration_to_millis(duration);
3
        if time_delta_ms > self.config.double_click_time_threshold_ms {
1
            return false;
2
        }
2
        let dx = last_first.position.x - prev_first.position.x;
2
        let dy = last_first.position.y - prev_first.position.y;
2
        let distance = dx.hypot(dy);
2
        distance < self.config.double_click_distance_threshold
116
    }
    /// 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).
81
    #[must_use] pub fn detect_click_count(&self) -> u32 {
81
        let sessions = &self.input_sessions;
81
        let n = sessions.len();
81
        if n == 0 {
74
            return 1;
7
        }
        // 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).
7
        let mut recent: Vec<&InputSession> = Vec::new();
17
        for s in sessions.iter().rev() {
17
            if !s.ended {
1
                continue;
16
            }
16
            recent.push(s);
16
            if recent.len() >= 3 {
4
                break;
12
            }
        }
7
        if recent.is_empty() {
1
            return 1;
6
        }
        // recent[0] = most recent ended session
        // recent[1] = previous ended session (if any)
        // recent[2] = one before that (if any)
6
        let mut count = 1u32;
9
        for i in 0..recent.len() - 1 {
9
            let later = recent[i];
9
            let earlier = recent[i + 1];
9
            let Some(later_start) = later.first_sample() else {
                break;
            };
9
            let Some(earlier_start) = earlier.first_sample() else {
1
                break;
            };
8
            let duration = later_start.timestamp.duration_since(&earlier_start.timestamp);
8
            let time_delta_ms = duration_to_millis(duration);
8
            if time_delta_ms > self.config.double_click_time_threshold_ms {
1
                break;
7
            }
7
            let dx = later_start.position.x - earlier_start.position.x;
7
            let dy = later_start.position.y - earlier_start.position.y;
7
            let distance = dx.hypot(dy);
7
            if distance >= self.config.double_click_distance_threshold {
1
                break;
6
            }
6
            count += 1;
        }
        // Cap at 3 (triple-click selects paragraph, beyond that cycles back)
6
        if count > 3 { 1 } else { count }
81
    }
    /// Get the primary direction of current drag.
10
    #[must_use] pub fn get_drag_direction(&self) -> Option<GestureDirection> {
10
        let session = self.get_current_session()?;
9
        let first = session.first_sample()?;
9
        let last = session.last_sample()?;
9
        let dx = last.position.x - first.position.x;
9
        let dy = last.position.y - first.position.y;
9
        let direction = match (dx.abs() > dy.abs(), dx > 0.0, dy > 0.0) {
2
            (true, true, _) => GestureDirection::Right,
1
            (true, false, _) => GestureDirection::Left,
2
            (false, _, true) => GestureDirection::Down,
4
            (false, _, false) => GestureDirection::Up,
        };
9
        Some(direction)
10
    }
    /// Get average velocity of current gesture (pixels per second)
    #[allow(clippy::cast_precision_loss)] // bounded layout/render numeric cast
118
    #[must_use] pub fn get_gesture_velocity(&self) -> Option<f32> {
118
        let session = self.get_current_session()?;
11
        if session.samples.len() < 2 {
1
            return None;
10
        }
10
        let total_distance = session.total_distance();
10
        let duration_ms = session.duration_ms()?;
10
        if duration_ms == 0 {
3
            return None;
7
        }
7
        let duration_secs = duration_ms as f32 / 1000.0;
7
        Some(total_distance / duration_secs)
118
    }
    /// Check if current gesture is a swipe (fast directional movement).
113
    #[must_use] pub fn is_swipe(&self) -> bool {
113
        self.get_gesture_velocity()
113
            .is_some_and(|v| v >= self.config.swipe_velocity_threshold)
113
    }
    /// Detect swipe with specific direction
    ///
    /// Returns Some(dir) if gesture is a fast swipe in a clear direction
112
    #[must_use] pub fn detect_swipe_direction(&self) -> Option<GestureDirection> {
23
        if let Some(NativeGestureEvent::Swipe(d)) = self.native_gesture {
5
            return Some(d);
107
        }
        // Must be a fast swipe first
107
        if !self.is_swipe() {
106
            return None;
1
        }
        // Get direction
1
        self.get_drag_direction()
112
    }
    /// 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.
    #[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
121
    #[must_use] pub fn detect_pinch(&self) -> Option<DetectedPinch> {
23
        if let Some(NativeGestureEvent::Pinch(p)) = self.native_gesture {
9
            return Some(p);
112
        }
        // Need at least two active sessions for pinch
112
        if self.input_sessions.len() < 2 {
105
            return None;
7
        }
        // Get last two sessions (most recent touches)
7
        let session1 = &self.input_sessions[self.input_sessions.len() - 2];
7
        let session2 = &self.input_sessions[self.input_sessions.len() - 1];
        // A pinch is a TWO-finger gesture: both contacts must be concurrently
        // active. A desktop mouse produces *sequential* sessions (the previous one
        // is `ended` on button-up before the next begins), so without this guard a
        // stale ended session (e.g. a prior click on a button) pairs with the
        // current drag and is misread as a pinch — the map zooms on a plain click.
7
        if session1.ended || session2.ended {
1
            return None;
6
        }
        // Both must have samples
6
        let first1 = session1.first_sample()?;
6
        let first2 = session2.first_sample()?;
6
        let last1 = session1.last_sample()?;
6
        let last2 = session2.last_sample()?;
        // Calculate initial distance between touches
6
        let dx_initial = first2.position.x - first1.position.x;
6
        let dy_initial = first2.position.y - first1.position.y;
6
        let initial_distance = dx_initial.hypot(dy_initial);
        // Calculate current distance
6
        let dx_current = last2.position.x - last1.position.x;
6
        let dy_current = last2.position.y - last1.position.y;
6
        let current_distance = dx_current.hypot(dy_current);
        // Avoid division by zero
6
        if initial_distance < 1.0 {
1
            return None;
5
        }
        // Calculate scale factor
5
        let scale = current_distance / initial_distance;
        // Check if scale change is significant (threshold from config)
5
        let scale_threshold = 1.0 + self.config.pinch_scale_threshold;
5
        if scale > 1.0 / scale_threshold && scale < scale_threshold {
1
            return None; // Change too small
4
        }
        // Calculate center point
4
        let center = LogicalPosition {
4
            x: f32::midpoint(last1.position.x, last2.position.x),
4
            y: f32::midpoint(last1.position.y, last2.position.y),
4
        };
        // Calculate duration
4
        let duration = last1.timestamp.duration_since(&first1.timestamp);
4
        let duration_ms = duration_to_millis(duration);
4
        Some(DetectedPinch {
4
            scale,
4
            center,
4
            initial_distance,
4
            current_distance,
4
            duration_ms,
4
        })
121
    }
    /// Detect rotation gesture (two-touch rotate)
    ///
    /// Returns Some if two touch points are rotating around center.
    /// Positive angle = clockwise, negative = counterclockwise.
    #[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
114
    #[must_use] pub fn detect_rotation(&self) -> Option<DetectedRotation> {
        const PI: f32 = core::f32::consts::PI;
23
        if let Some(NativeGestureEvent::Rotation(r)) = self.native_gesture {
8
            return Some(r);
106
        }
        // Need at least two active sessions
106
        if self.input_sessions.len() < 2 {
101
            return None;
5
        }
        // Get last two sessions
5
        let session1 = &self.input_sessions[self.input_sessions.len() - 2];
5
        let session2 = &self.input_sessions[self.input_sessions.len() - 1];
        // Two-finger rotation requires both contacts concurrently active; a desktop
        // mouse yields sequential sessions, so a stale ended session must not pair
        // with the current one (see detect_pinch).
5
        if session1.ended || session2.ended {
1
            return None;
4
        }
        // Both must have samples
4
        let first1 = session1.first_sample()?;
4
        let first2 = session2.first_sample()?;
4
        let last1 = session1.last_sample()?;
4
        let last2 = session2.last_sample()?;
        // Calculate center (average of both touches)
4
        let center = LogicalPosition {
4
            x: f32::midpoint(last1.position.x, last2.position.x),
4
            y: f32::midpoint(last1.position.y, last2.position.y),
4
        };
        // Calculate initial angle between touches
4
        let dx_initial = first2.position.x - first1.position.x;
4
        let dy_initial = first2.position.y - first1.position.y;
4
        let initial_angle = dy_initial.atan2(dx_initial);
        // Calculate current angle
4
        let dx_current = last2.position.x - last1.position.x;
4
        let dy_current = last2.position.y - last1.position.y;
4
        let current_angle = dy_current.atan2(dx_current);
        // Calculate angle difference (normalized to -π to π)
4
        let mut angle_diff = current_angle - initial_angle;
        // Normalize angle to -π to π range
        #[allow(clippy::while_float)] // intentional bounded float loop (angle-wrap / pixel-step); an integer counter would be artificial
4
        while angle_diff > PI {
            angle_diff -= 2.0 * PI;
        }
        #[allow(clippy::while_float)] // intentional bounded float loop (angle-wrap / pixel-step); an integer counter would be artificial
4
        while angle_diff < -PI {
            angle_diff += 2.0 * PI;
        }
        // Check if rotation is significant (threshold from config)
4
        if angle_diff.abs() < self.config.rotation_angle_threshold {
1
            return None;
3
        }
        // Calculate duration
3
        let duration = last1.timestamp.duration_since(&first1.timestamp);
3
        let duration_ms = duration_to_millis(duration);
3
        Some(DetectedRotation {
3
            angle_radians: angle_diff,
3
            center,
3
            duration_ms,
3
        })
114
    }
    /// Get the current active input session (if any)
1601
    #[must_use] pub fn get_current_session(&self) -> Option<&InputSession> {
1601
        self.input_sessions.last()
1601
    }
    /// Get current mouse position from latest sample
3
    #[must_use] pub fn get_current_mouse_position(&self) -> Option<LogicalPosition> {
3
        self.get_current_session()
3
            .and_then(|s| s.last_sample())
3
            .map(|sample| sample.position)
3
    }
    /// 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.
5
    #[must_use] pub fn get_drag_delta(&self) -> Option<(f32, f32)> {
5
        let session = self.get_current_session()?;
3
        let first = session.first_sample()?;
3
        let last = session.last_sample()?;
3
        Some((
3
            last.position.x - first.position.x,
3
            last.position.y - first.position.y,
3
        ))
5
    }
    /// 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.
5
    #[must_use] pub fn get_drag_delta_screen(&self) -> Option<(f32, f32)> {
5
        let session = self.get_current_session()?;
3
        let first = session.first_sample()?;
3
        let last = session.last_sample()?;
3
        Some((
3
            last.screen_position.x - first.screen_position.x,
3
            last.screen_position.y - first.screen_position.y,
3
        ))
5
    }
    /// 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.
10
    #[must_use] pub fn get_drag_delta_screen_incremental(&self) -> Option<(f32, f32)> {
10
        let session = self.get_current_session()?;
2
        let len = session.samples.len();
2
        if len < 2 {
1
            return None;
1
        }
1
        let prev = &session.samples[len - 2];
1
        let last = &session.samples[len - 1];
1
        Some((
1
            last.screen_position.x - prev.screen_position.x,
1
            last.screen_position.y - prev.screen_position.y,
1
        ))
10
    }
    /// 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.
2
    #[must_use] pub fn get_window_position_at_session_start(&self) -> Option<WindowPosition> {
2
        let session = self.get_current_session()?;
1
        Some(session.window_position_at_start)
2
    }
    // ========================================================================
    // UNIFIED DRAG CONTEXT API (NEW)
    // ========================================================================
    /// Get the active drag context (if any)
8
    #[must_use] pub const fn get_drag_context(&self) -> Option<&DragContext> {
8
        self.active_drag.as_ref()
8
    }
    /// Get the active drag context mutably (if any)
1
    pub const fn get_drag_context_mut(&mut self) -> Option<&mut DragContext> {
1
        self.active_drag.as_mut()
1
    }
    // NOTE: text-selection and scrollbar-thumb drags do NOT flow through this
    // manager's `active_drag`. Text selection is driven by `MultiCursorState`
    // (managers/selection.rs) and scrollbar dragging by `ScrollbarDragState`
    // (window.rs, set in common/event.rs). The former `activate_text_selection_drag`
    // / `activate_scrollbar_drag` constructors here were dead duplicates of those
    // paths (zero callers) and were removed.
    /// Activate a node drag-and-drop
3
    pub fn activate_node_drag(
3
        &mut self,
3
        dom_id: DomId,
3
        node_id: NodeId,
3
        drag_data: DragData,
3
        _start_hit_test: Option<HitTest>,
3
    ) {
3
        if let Some(detected) = self.detect_drag() {
1
            self.active_drag = Some(DragContext::node_drag(
1
                dom_id,
1
                node_id,
1
                detected.start_position,
1
                drag_data,
1
                detected.session_id,
1
            ));
2
        }
3
    }
    /// Activate a window move drag (titlebar)
6
    pub fn activate_window_drag(
6
        &mut self,
6
        initial_window_position: WindowPosition,
6
        _start_hit_test: Option<HitTest>,
6
    ) {
6
        if let Some(detected) = self.detect_drag() {
5
            self.active_drag = Some(DragContext::window_move(
5
                detected.start_position,
5
                initial_window_position,
5
                detected.session_id,
5
            ));
5
        }
6
    }
    // NOTE: OS file drops are tracked by `FileDropManager` (managers/file_drop.rs),
    // not by this manager's `active_drag`. The former `start_file_drop` constructor
    // here was a dead duplicate (zero callers) and was removed.
    /// Update positions for active drag (call on mouse move)
12
    pub const fn update_active_drag_positions(&mut self, position: LogicalPosition) {
12
        if let Some(ref mut drag) = self.active_drag {
11
            drag.update_position(position);
11
        }
12
    }
    /// Update drop target for node or file drag
4
    pub fn update_drop_target(&mut self, target: Option<azul_core::dom::DomNodeId>) {
4
        if let Some(ref mut drag) = self.active_drag {
3
            match &mut drag.drag_type {
2
                ActiveDragType::Node(ref mut node_drag) => {
2
                    node_drag.current_drop_target = target.into();
2
                }
                ActiveDragType::FileDrop(ref mut file_drop) => {
                    file_drop.drop_target = target.into();
                }
1
                _ => {}
            }
1
        }
4
    }
    /// End the current drag and return the context
2
    pub const fn end_drag(&mut self) -> Option<DragContext> {
2
        self.active_drag.take()
2
    }
    /// Cancel the current drag
2
    pub fn cancel_drag(&mut self) {
2
        if let Some(ref mut drag) = self.active_drag {
1
            drag.cancelled = true;
1
        }
2
        self.active_drag = None;
2
    }
    // ========================================================================
    // QUERY METHODS
    // ========================================================================
    /// Check if any drag operation is in progress
220
    #[must_use] pub const fn is_dragging(&self) -> bool {
220
        self.active_drag.is_some()
220
    }
    /// Check if a text selection drag is active
5
    #[must_use] pub fn is_text_selection_dragging(&self) -> bool {
5
        self.active_drag.as_ref().is_some_and(DragContext::is_text_selection)
5
    }
    /// Check if a scrollbar thumb drag is active
3
    #[must_use] pub fn is_scrollbar_dragging(&self) -> bool {
3
        self.active_drag.as_ref().is_some_and(DragContext::is_scrollbar_thumb)
3
    }
    /// Check if a node drag is active
112
    #[must_use] pub fn is_node_drag_active(&self) -> bool {
112
        self.active_drag.as_ref().is_some_and(DragContext::is_node_drag)
112
    }
    /// Check if a specific node is being dragged
6
    #[must_use] pub fn is_node_dragging(&self, dom_id: DomId, node_id: NodeId) -> bool {
6
        self.active_drag.as_ref().is_some_and(|d| {
5
            d.as_node_drag().is_some_and(|node_drag| node_drag.dom_id == dom_id && node_drag.node_id == node_id)
5
        })
6
    }
    /// Check if window drag is active
8
    #[must_use] pub fn is_window_dragging(&self) -> bool {
8
        self.active_drag.as_ref().is_some_and(DragContext::is_window_move)
8
    }
    /// Check if file drop is active
4
    #[must_use] pub fn is_file_dropping(&self) -> bool {
4
        self.active_drag.as_ref().is_some_and(DragContext::is_file_drop)
4
    }
    /// Get number of active input sessions
12
    #[must_use] pub const fn session_count(&self) -> usize {
12
        self.input_sessions.len()
12
    }
    /// Get current session ID (if any)
7
    #[must_use] pub fn current_session_id(&self) -> Option<u64> {
7
        self.get_current_session().map(|s| s.session_id)
7
    }
    // ========================================================================
    // 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.
    #[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
6
    #[must_use] pub fn get_window_drag_delta(&self) -> Option<(i32, i32)> {
6
        let drag = self.active_drag.as_ref()?.as_window_move()?;
4
        let delta_x = drag.current_position.x - drag.start_position.x;
4
        let delta_y = drag.current_position.y - drag.start_position.y;
4
        match drag.initial_window_position {
3
            WindowPosition::Initialized(_initial_pos) => Some((delta_x as i32, delta_y as i32)),
1
            _ => None,
        }
6
    }
    /// Get the new window position based on current drag
    ///
    /// Returns the absolute window position to set.
    #[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
6
    #[must_use] pub fn get_window_position_from_drag(&self) -> Option<WindowPosition> {
6
        let drag = self.active_drag.as_ref()?.as_window_move()?;
5
        let delta_x = drag.current_position.x - drag.start_position.x;
5
        let delta_y = drag.current_position.y - drag.start_position.y;
5
        match drag.initial_window_position {
4
            WindowPosition::Initialized(initial_pos) => {
4
                Some(WindowPosition::Initialized(PhysicalPositionI32::new(
4
                    initial_pos.x + delta_x as i32,
4
                    initial_pos.y + delta_y as i32,
4
                )))
            }
1
            _ => None,
        }
6
    }
    /// Calculate the new scroll offset for scrollbar thumb drag
9
    #[must_use] pub fn get_scrollbar_scroll_offset(&self) -> Option<f32> {
9
        self.active_drag.as_ref()?.calculate_scrollbar_scroll_offset()
9
    }
}
impl crate::managers::NodeIdRemap for GestureAndDragManager {
    /// Remap `NodeIds` in the active drag context after DOM reconciliation.
    ///
    /// When the DOM is regenerated during an active drag, `NodeIds` change.
    /// If a critical `NodeId` was unmounted, the drag is cancelled (an active
    /// drag whose source node no longer exists cannot be completed).
26
    fn remap_node_ids(&mut self, dom_id: DomId, map: &crate::managers::NodeIdMap) {
26
        if let Some(ref mut drag) = self.active_drag {
2
            if !drag.remap_node_ids(dom_id, map.as_btree_map()) {
1
                // Critical node removed — cancel the drag
1
                drag.cancelled = true;
1
                self.active_drag = None;
1
            }
24
        }
26
    }
}
#[cfg(test)]
mod touch_session_tests {
    use super::*;
    use azul_core::task::{Instant as TestInstant, SystemTick};
    /// A timestamp `n` MILLISECONDS from the origin.
    ///
    /// This used to build `Tick(n)` while every caller named its argument
    /// `hold_ms` / `gap_ms`. That was harmless only while a tick was assumed to
    /// be one millisecond; once `duration_to_millis` started converting ticks at
    /// the real frame rate (~16.67 ms), every gesture threshold in this module —
    /// double-click window, long-press time, swipe velocity — was being compared
    /// against a duration 16x longer than the test meant, and six tests failed.
    ///
    /// These tests are about gesture SEMANTICS, not about the tick/ms constant,
    /// so they express time in the same unit the thresholds use.
    /// `duration_to_millis_*` still pins the conversion itself.
13
    fn ts(n: u64) -> CoreInstant {
        // `Instant::System` wraps a real std Instant, so "n ms from the origin"
        // needs a STABLE origin — a fresh `now()` per call would make ts(0) and
        // ts(20) differ by however long the test took, not by 20 ms. One
        // process-wide base keeps every difference exact.
        use std::sync::OnceLock;
        static BASE: OnceLock<std::time::Instant> = OnceLock::new();
13
        let base = *BASE.get_or_init(std::time::Instant::now);
13
        (base + core::time::Duration::from_millis(n)).into()
13
    }
26
    fn pos(x: f32, y: f32) -> LogicalPosition {
26
        LogicalPosition { x, y }
26
    }
    #[test]
1
    fn two_fingers_open_two_concurrent_sessions() {
1
        let mut m = GestureAndDragManager::new();
1
        m.touch_down(1, pos(100.0, 100.0), ts(0), WindowPosition::Uninitialized, pos(100.0, 100.0));
1
        m.touch_down(2, pos(200.0, 100.0), ts(1), WindowPosition::Uninitialized, pos(200.0, 100.0));
1
        assert_eq!(m.input_sessions.len(), 2);
1
        assert!(!m.input_sessions[0].ended);
1
        assert!(!m.input_sessions[1].ended);
1
    }
    #[test]
1
    fn moves_land_in_the_correct_session_not_the_last_one() {
1
        let mut m = GestureAndDragManager::new();
1
        m.touch_down(1, pos(100.0, 100.0), ts(0), WindowPosition::Uninitialized, pos(100.0, 100.0));
1
        m.touch_down(2, pos(200.0, 100.0), ts(1), WindowPosition::Uninitialized, pos(200.0, 100.0));
        // Move finger 1 — the FIRST session must receive the sample even
        // though session 2 is the most recent (record_input_sample would
        // have corrupted session 2 here).
1
        assert!(m.touch_move(1, pos(90.0, 100.0), ts(2), pos(90.0, 100.0)));
1
        assert_eq!(m.input_sessions[0].samples.len(), 2, "finger 1 session grew");
1
        assert_eq!(m.input_sessions[1].samples.len(), 1, "finger 2 session untouched");
1
    }
    #[test]
1
    fn spread_gesture_is_detected_as_pinch_out() {
1
        let mut m = GestureAndDragManager::new();
1
        m.touch_down(1, pos(100.0, 100.0), ts(0), WindowPosition::Uninitialized, pos(100.0, 100.0));
1
        m.touch_down(2, pos(200.0, 100.0), ts(1), WindowPosition::Uninitialized, pos(200.0, 100.0));
        // Spread: initial distance 100 → current distance 200.
1
        m.touch_move(1, pos(50.0, 100.0), ts(2), pos(50.0, 100.0));
1
        m.touch_move(2, pos(250.0, 100.0), ts(3), pos(250.0, 100.0));
1
        let pinch = m.detect_pinch().expect("two concurrent touch sessions must yield a pinch");
1
        assert!(
1
            pinch.scale > 1.5,
            "spread must read as pinch-out (scale {}), initial {} current {}",
            pinch.scale,
            pinch.initial_distance,
            pinch.current_distance
        );
1
    }
    #[test]
1
    fn touch_up_ends_only_its_own_session() {
1
        let mut m = GestureAndDragManager::new();
1
        m.touch_down(1, pos(100.0, 100.0), ts(0), WindowPosition::Uninitialized, pos(100.0, 100.0));
1
        m.touch_down(2, pos(200.0, 100.0), ts(1), WindowPosition::Uninitialized, pos(200.0, 100.0));
1
        m.touch_up(1, pos(100.0, 100.0), ts(2), pos(100.0, 100.0));
1
        assert!(m.input_sessions[0].ended);
1
        assert!(!m.input_sessions[1].ended);
        // Further moves for the lifted finger are ignored.
1
        assert!(!m.touch_move(1, pos(0.0, 0.0), ts(3), pos(0.0, 0.0)));
1
    }
}
#[cfg(test)]
#[allow(clippy::float_cmp, clippy::unreadable_literal)]
mod autotest_generated {
    use azul_core::{
        drag::ScrollbarAxis, geom::PhysicalPositionI32, styled_dom::NodeHierarchyItemId,
        task::{SystemTick, SystemTickDiff, SystemTimeDiff},
    };
    use super::*;
    // ---------------------------------------------------------------- helpers
    /// Tick-based instant: 1 tick == 1 ms for `duration_to_millis`.
    /// A timestamp `n` MILLISECONDS from the origin — see the note on the other
    /// `ts` in this file.
    fn ts(n: u64) -> CoreInstant {
        // `Instant::System` wraps a real std Instant, so "n ms from the origin"
        // needs a STABLE origin — a fresh `now()` per call would make ts(0) and
        // ts(20) differ by however long the test took, not by 20 ms. One
        // process-wide base keeps every difference exact.
        use std::sync::OnceLock;
        static BASE: OnceLock<std::time::Instant> = OnceLock::new();
        let base = *BASE.get_or_init(std::time::Instant::now);
        (base + core::time::Duration::from_millis(n)).into()
    }
    fn pos(x: f32, y: f32) -> LogicalPosition {
        LogicalPosition { x, y }
    }
    /// A sample with window-local == screen position (the common mouse case).
    fn sample(x: f32, y: f32, tick: u64) -> InputSample {
        InputSample {
            position: pos(x, y),
            screen_position: pos(x, y),
            timestamp: ts(tick),
            button_state: 0x01,
            event_id: 0,
            pressure: 0.5,
            tilt: (0.0, 0.0),
            touch_radius: (0.0, 0.0),
        }
    }
    /// A synthetic *ended* session — the only way to build a >2-click history,
    /// because `start_input_session` prunes all but the newest ended session.
    fn ended_session(session_id: u64, samples: Vec<InputSample>) -> InputSession {
        InputSession {
            samples,
            ended: true,
            session_id,
            window_position_at_start: WindowPosition::Uninitialized,
        }
    }
    /// Press at `from`, move to `to`, `hold_ms` apart — a session that
    /// `detect_drag` will accept (distance permitting).
    fn dragging_manager(
        from: LogicalPosition,
        to: LogicalPosition,
        hold_ms: u64,
    ) -> GestureAndDragManager {
        let mut m = GestureAndDragManager::new();
        m.start_input_session(from, ts(0), 0x01, WindowPosition::Uninitialized, from);
        let recorded = m.record_input_sample(to, ts(hold_ms), 0x01, to);
        assert!(recorded);
        m
    }
    // ------------------------------------------------- duration_to_millis (private)
    #[test]
    fn duration_to_millis_tick_zero_and_max_do_not_panic() {
        assert_eq!(
            duration_to_millis(CoreDuration::Tick(SystemTickDiff { tick_diff: 0 })),
            0
        );
        assert_eq!(
            duration_to_millis(CoreDuration::Tick(SystemTickDiff {
                tick_diff: u64::MAX
            })),
            u64::MAX
        );
    }
    /// A tick is a FRAME, so it converts at the nominal frame rate. Returning the
    /// raw tick count made every gesture threshold read a 16x-too-small number of
    /// milliseconds — a 60-frame hold looked like 60ms, well under any
    /// long-press threshold.
    #[test]
    fn duration_to_millis_converts_ticks_at_the_nominal_frame_rate() {
        assert_eq!(
            duration_to_millis(CoreDuration::Tick(SystemTickDiff { tick_diff: 60 })),
            1_000
        );
        assert_eq!(
            duration_to_millis(CoreDuration::Tick(SystemTickDiff { tick_diff: 30 })),
            500
        );
        assert_eq!(
            duration_to_millis(CoreDuration::Tick(SystemTickDiff { tick_diff: 1 })),
            16
        );
    }
    #[cfg(feature = "std")]
    #[test]
    fn duration_to_millis_system_zero_and_sub_millisecond_floor() {
        assert_eq!(
            duration_to_millis(CoreDuration::System(SystemTimeDiff { secs: 0, nanos: 0 })),
            0
        );
        // 999_999 ns is under a millisecond => floors to 0, never rounds up.
        assert_eq!(
            duration_to_millis(CoreDuration::System(SystemTimeDiff {
                secs: 0,
                nanos: 999_999
            })),
            0
        );
        assert_eq!(
            duration_to_millis(CoreDuration::System(SystemTimeDiff {
                secs: 2,
                nanos: 500_000_000
            })),
            2500
        );
    }
    #[cfg(feature = "std")]
    #[test]
    fn duration_to_millis_system_max_truncates_instead_of_panicking() {
        // as_millis() is u128 and would be MAX*1000+999; the `as u64` cast
        // truncates rather than panicking or saturating. Lock the exact value
        // so a change to saturating semantics is caught.
        let d = CoreDuration::System(SystemTimeDiff {
            secs: u64::MAX,
            nanos: 999_999_999,
        });
        let expected = ((u64::MAX as u128) * 1000 + 999) as u64;
        assert_eq!(duration_to_millis(d), expected);
    }
    // ------------------------------------------------- WacomPadState::express_key
    #[test]
    fn express_key_out_of_range_index_is_false_not_a_shift_overflow() {
        // 1u32 << 32 would panic in debug; the `index < 32` guard must short-circuit.
        let pad = WacomPadState {
            express_keys: u32::MAX,
            touch_ring: 0.0,
            touch_ring_active: false,
            device_id: 0,
        };
        assert!(pad.express_key(31));
        assert!(!pad.express_key(32));
        assert!(!pad.express_key(33));
        assert!(!pad.express_key(u32::MAX));
    }
    #[test]
    fn express_key_default_pad_has_no_keys_held() {
        let pad = WacomPadState::default();
        for i in 0..40u32 {
            assert!(!pad.express_key(i), "bit {i} must be unset on a default pad");
        }
    }
    #[test]
    fn express_key_bitset_round_trips_every_bit() {
        for bit in 0..32u32 {
            let pad = WacomPadState {
                express_keys: 1u32 << bit,
                touch_ring: 0.0,
                touch_ring_active: false,
                device_id: 0,
            };
            for probe in 0..32u32 {
                assert_eq!(
                    pad.express_key(probe),
                    probe == bit,
                    "encode bit {bit} -> decode probe {probe}"
                );
            }
        }
    }
    // ------------------------------------------------- InputSession
    #[test]
    fn input_session_new_holds_its_construction_invariants() {
        let s = InputSession::new(
            u64::MAX,
            sample(1.0, 2.0, 7),
            WindowPosition::Initialized(PhysicalPositionI32::new(-5, 9)),
        );
        assert_eq!(s.session_id, u64::MAX);
        assert!(!s.ended);
        assert_eq!(s.samples.len(), 1);
        assert_eq!(s.first_sample(), s.last_sample());
        assert_eq!(
            s.window_position_at_start,
            WindowPosition::Initialized(PhysicalPositionI32::new(-5, 9))
        );
        assert_eq!(s.total_distance(), 0.0);
        assert_eq!(s.direct_distance(), Some(0.0));
        assert_eq!(s.duration_ms(), Some(0));
    }
    #[test]
    fn empty_session_getters_return_none_instead_of_panicking() {
        let s = InputSession {
            samples: Vec::new(),
            ended: false,
            session_id: 0,
            window_position_at_start: WindowPosition::Uninitialized,
        };
        assert!(s.first_sample().is_none());
        assert!(s.last_sample().is_none());
        assert!(s.duration_ms().is_none());
        assert!(s.direct_distance().is_none());
        assert_eq!(s.total_distance(), 0.0);
    }
    #[test]
    fn duration_ms_saturates_to_zero_when_time_runs_backwards() {
        // last sample is *earlier* than the first (reordered / skewed clock).
        let s = InputSession {
            samples: vec![sample(0.0, 0.0, 900), sample(0.0, 0.0, 100)],
            ended: false,
            session_id: 1,
            window_position_at_start: WindowPosition::Uninitialized,
        };
        assert_eq!(s.duration_ms(), Some(0));
    }
    #[cfg(feature = "std")]
    #[test]
    fn duration_ms_with_mismatched_instant_kinds_is_zero() {
        // Both variants are named EXPLICITLY. This used to lean on `sample()`
        // happening to produce a Tick, so when `ts()` moved to System the
        // "mismatch" quietly became System-vs-System and the test asserted
        // nothing — it measured 4997 ms and expected 0. An invariant about
        // mismatched kinds must construct both kinds itself.
        let mut first = sample(0.0, 0.0, 0);
        first.timestamp = CoreInstant::now(); // System variant
        let mut last = sample(0.0, 0.0, 5_000);
        last.timestamp = CoreInstant::Tick(SystemTick::new(5_000)); // Tick variant
        let s = InputSession {
            samples: vec![first, last],
            ended: false,
            session_id: 1,
            window_position_at_start: WindowPosition::Uninitialized,
        };
        assert_eq!(s.duration_ms(), Some(0));
    }
    #[test]
    fn total_distance_sums_the_path_while_direct_distance_is_the_chord() {
        let s = InputSession {
            samples: vec![
                sample(0.0, 0.0, 0),
                sample(3.0, 0.0, 1),
                sample(3.0, 4.0, 2),
            ],
            ended: false,
            session_id: 1,
            window_position_at_start: WindowPosition::Uninitialized,
        };
        assert_eq!(s.total_distance(), 7.0);
        assert_eq!(s.direct_distance(), Some(5.0));
    }
    #[test]
    fn distances_with_nan_coordinates_are_nan_and_do_not_panic() {
        let s = InputSession {
            samples: vec![sample(0.0, 0.0, 0), sample(f32::NAN, f32::NAN, 1)],
            ended: false,
            session_id: 1,
            window_position_at_start: WindowPosition::Uninitialized,
        };
        assert!(s.total_distance().is_nan());
        assert!(s.direct_distance().is_some_and(f32::is_nan));
    }
    #[test]
    fn distances_at_f32_extremes_saturate_to_infinity_instead_of_panicking() {
        let s = InputSession {
            samples: vec![
                sample(-f32::MAX, -f32::MAX, 0),
                sample(f32::MAX, f32::MAX, 1),
            ],
            ended: false,
            session_id: 1,
            window_position_at_start: WindowPosition::Uninitialized,
        };
        assert!(s.total_distance().is_infinite());
        assert!(s.direct_distance().is_some_and(f32::is_infinite));
    }
    // ------------------------------------------------- construction
    #[test]
    fn new_manager_is_inert_and_every_detector_is_quiet() {
        let m = GestureAndDragManager::new();
        assert_eq!(m.session_count(), 0);
        assert_eq!(m.debug_counts(), (0, 0));
        assert!(m.current_session_id().is_none());
        assert!(m.get_current_session().is_none());
        assert!(m.get_current_mouse_position().is_none());
        assert!(m.get_pen_state().is_none());
        assert!(m.get_previous_pen_state().is_none());
        assert!(m.get_pad_state().is_none());
        assert!(m.get_drag_context().is_none());
        assert!(m.detect_drag().is_none());
        assert!(m.detect_long_press().is_none());
        assert!(!m.detect_double_click());
        assert!(m.get_drag_direction().is_none());
        assert!(m.get_gesture_velocity().is_none());
        assert!(!m.is_swipe());
        assert!(m.detect_swipe_direction().is_none());
        assert!(m.detect_pinch().is_none());
        assert!(m.detect_rotation().is_none());
        assert!(m.get_drag_delta().is_none());
        assert!(m.get_drag_delta_screen().is_none());
        assert!(m.get_drag_delta_screen_incremental().is_none());
        assert!(m.get_window_position_at_session_start().is_none());
        assert!(m.get_window_drag_delta().is_none());
        assert!(m.get_window_position_from_drag().is_none());
        assert!(m.get_scrollbar_scroll_offset().is_none());
        assert!(!m.is_dragging());
        assert!(!m.is_text_selection_dragging());
        assert!(!m.is_scrollbar_dragging());
        assert!(!m.is_node_drag_active());
        assert!(!m.is_window_dragging());
        assert!(!m.is_file_dropping());
        assert!(!m.is_node_dragging(DomId::ROOT_ID, NodeId::ZERO));
        // Documented default for "no history at all".
        assert_eq!(m.detect_click_count(), 1);
        assert_eq!(m, GestureAndDragManager::default());
    }
    #[test]
    fn with_config_keeps_extreme_thresholds_verbatim_and_still_starts_at_session_1() {
        let cfg = GestureDetectionConfig {
            drag_distance_threshold: f32::NAN,
            double_click_time_threshold_ms: u64::MAX,
            double_click_distance_threshold: f32::INFINITY,
            long_press_time_threshold_ms: 0,
            long_press_distance_threshold: -1.0,
            min_samples_for_gesture: usize::MAX,
            swipe_velocity_threshold: 0.0,
            pinch_scale_threshold: f32::MAX,
            rotation_angle_threshold: -0.0,
            sample_cleanup_interval_ms: 0,
        };
        let mut m = GestureAndDragManager::with_config(cfg);
        assert!(m.config.drag_distance_threshold.is_nan());
        assert_eq!(m.config.double_click_time_threshold_ms, u64::MAX);
        assert_eq!(m.config.min_samples_for_gesture, usize::MAX);
        assert_eq!(m.session_count(), 0);
        let id = m.start_input_session(
            pos(0.0, 0.0),
            ts(0),
            0x01,
            WindowPosition::Uninitialized,
            pos(0.0, 0.0),
        );
        assert_eq!(id, 1, "with_config must not disturb the session counter");
        // min_samples_for_gesture == usize::MAX can never be reached => no drag.
        assert!(m.detect_drag().is_none());
    }
    // ------------------------------------------------- session recording
    #[test]
    fn session_ids_are_monotonic_starting_at_one() {
        let mut m = GestureAndDragManager::new();
        for expected in 1..=5u64 {
            let id = m.start_input_session(
                pos(0.0, 0.0),
                ts(expected),
                0x01,
                WindowPosition::Uninitialized,
                pos(0.0, 0.0),
            );
            assert_eq!(id, expected);
            assert_eq!(m.current_session_id(), Some(expected));
            m.end_current_session();
        }
    }
    #[test]
    fn session_id_counter_at_the_u64_boundary_does_not_overflow() {
        let mut m = GestureAndDragManager::new();
        m.next_session_id = u64::MAX - 1;
        let id = m.start_input_session(
            pos(0.0, 0.0),
            ts(0),
            0xFF,
            WindowPosition::Uninitialized,
            pos(0.0, 0.0),
        );
        assert_eq!(id, u64::MAX - 1);
        assert_eq!(m.next_session_id, u64::MAX);
    }
    #[test]
    fn recording_without_or_after_a_session_returns_false() {
        let mut m = GestureAndDragManager::new();
        assert!(!m.record_input_sample(pos(1.0, 1.0), ts(1), 0x01, pos(1.0, 1.0)));
        m.start_input_session(
            pos(0.0, 0.0),
            ts(0),
            0x01,
            WindowPosition::Uninitialized,
            pos(0.0, 0.0),
        );
        assert!(m.record_input_sample(pos(1.0, 1.0), ts(1), 0x01, pos(1.0, 1.0)));
        m.end_current_session();
        assert!(!m.record_input_sample(pos(2.0, 2.0), ts(2), 0x01, pos(2.0, 2.0)));
        // Ending twice is idempotent, and ending nothing must not panic.
        m.end_current_session();
        m.clear_all_sessions();
        m.end_current_session();
        assert_eq!(m.session_count(), 0);
    }
    #[test]
    fn sample_count_stays_bounded_by_max_samples_per_session() {
        let mut m = GestureAndDragManager::new();
        m.start_input_session(
            pos(0.0, 0.0),
            ts(0),
            0x01,
            WindowPosition::Uninitialized,
            pos(0.0, 0.0),
        );
        for i in 1..=(MAX_SAMPLES_PER_SESSION as u64 + 200) {
            assert!(m.record_input_sample(pos(i as f32, 0.0), ts(i), 0x01, pos(i as f32, 0.0)));
            assert!(
                m.get_current_session().unwrap().samples.len() <= MAX_SAMPLES_PER_SESSION,
                "sample buffer grew past MAX_SAMPLES_PER_SESSION at i={i}"
            );
        }
        // The newest sample always survives the drain.
        let last = m.get_current_mouse_position().unwrap();
        assert_eq!(last.x, (MAX_SAMPLES_PER_SESSION + 200) as f32);
    }
    #[test]
    fn pen_samples_accept_nan_inf_and_extreme_values() {
        let mut m = GestureAndDragManager::new();
        let id = m.start_input_session_with_pen(
            pos(f32::NAN, f32::INFINITY),
            ts(0),
            0xFF,
            u64::MAX,
            f32::NAN,
            (f32::INFINITY, f32::NEG_INFINITY),
            (-f32::MAX, f32::MAX),
            WindowPosition::Uninitialized,
            pos(f32::NEG_INFINITY, f32::NAN),
        );
        assert_eq!(id, 1);
        assert!(m.record_input_sample_with_pen(
            pos(0.0, 0.0),
            ts(u64::MAX),
            0x00,
            0,
            -1.0e30,
            (f32::NAN, f32::NAN),
            (f32::NAN, f32::NAN),
            pos(0.0, 0.0),
        ));
        let session = m.get_current_session().unwrap();
        assert_eq!(session.samples.len(), 2);
        let first = session.first_sample().unwrap();
        assert!(first.pressure.is_nan());
        assert!(first.tilt.0.is_infinite());
        assert_eq!(first.button_state, 0xFF);
        assert_eq!(first.event_id, u64::MAX);
        // ts(u64::MAX) - ts(0) fits: duration_since is a saturating u64 sub.
        assert_eq!(session.duration_ms(), Some(u64::MAX));
        // NaN/inf coordinates must not make any detector panic. `hypot(NaN, inf)`
        // is `+inf` per IEEE-754, so this DOES read as a drag — but only with a
        // non-finite distance, never a plausible-looking finite one.
        assert!(m.detect_drag().is_none_or(|d| !d.direct_distance.is_finite()));
        assert!(m.get_drag_direction().is_some());
    }
    #[test]
    fn starting_a_session_prunes_all_but_the_newest_ended_session() {
        let mut m = GestureAndDragManager::new();
        for tick in [0u64, 10, 20] {
            m.start_input_session(
                pos(0.0, 0.0),
                ts(tick),
                0x01,
                WindowPosition::Uninitialized,
                pos(0.0, 0.0),
            );
            m.end_current_session();
        }
        // Bounded growth: never more than "one ended + one live" session.
        assert_eq!(m.session_count(), 2);
        assert_eq!(m.input_sessions[0].session_id, 2);
        assert_eq!(m.input_sessions[1].session_id, 3);
        // KNOWN LIMITATION: because the history is pruned to a single ended
        // session, a genuine triple-click through the public API can only ever
        // report 2. detect_click_count()'s triple-click arm is unreachable here.
        assert_eq!(m.detect_click_count(), 2);
    }
    // ------------------------------------------------- touch sessions
    #[test]
    fn touch_ids_at_zero_and_u64_max_are_tracked_independently() {
        let mut m = GestureAndDragManager::new();
        m.touch_down(
            0,
            pos(0.0, 0.0),
            ts(0),
            WindowPosition::Uninitialized,
            pos(0.0, 0.0),
        );
        m.touch_down(
            u64::MAX,
            pos(50.0, 0.0),
            ts(1),
            WindowPosition::Uninitialized,
            pos(50.0, 0.0),
        );
        assert_eq!(m.session_count(), 2);
        assert!(m.touch_move(0, pos(1.0, 1.0), ts(2), pos(1.0, 1.0)));
        assert!(m.touch_move(u64::MAX, pos(60.0, 0.0), ts(3), pos(60.0, 0.0)));
        assert_eq!(m.input_sessions[0].samples.len(), 2);
        assert_eq!(m.input_sessions[1].samples.len(), 2);
        m.touch_up(0, pos(1.0, 1.0), ts(4), pos(1.0, 1.0));
        assert!(m.input_sessions[0].ended);
        assert!(!m.input_sessions[1].ended);
    }
    #[test]
    fn touch_events_for_unknown_ids_are_ignored_without_panicking() {
        let mut m = GestureAndDragManager::new();
        assert!(!m.touch_move(42, pos(0.0, 0.0), ts(0), pos(0.0, 0.0)));
        m.touch_up(42, pos(0.0, 0.0), ts(1), pos(0.0, 0.0));
        m.touch_cancel_all(); // nothing to cancel
        assert_eq!(m.session_count(), 0);
    }
    #[test]
    fn a_repeated_touch_down_for_the_same_id_rebinds_to_the_newest_session() {
        let mut m = GestureAndDragManager::new();
        m.touch_down(
            7,
            pos(0.0, 0.0),
            ts(0),
            WindowPosition::Uninitialized,
            pos(0.0, 0.0),
        );
        m.touch_down(
            7,
            pos(9.0, 9.0),
            ts(1),
            WindowPosition::Uninitialized,
            pos(9.0, 9.0),
        );
        assert_eq!(m.touch_sessions.len(), 1, "the id map must not grow");
        assert_eq!(m.session_count(), 2);
        assert_eq!(m.touch_sessions.get(&7).copied(), Some(2));
        // touch_up ends only the session the id currently maps to; the orphaned
        // first session stays open until clear_old_sessions() reaps it.
        m.touch_up(7, pos(9.0, 9.0), ts(2), pos(9.0, 9.0));
        assert!(!m.input_sessions[0].ended);
        assert!(m.input_sessions[1].ended);
        assert!(m.touch_sessions.is_empty());
    }
    #[test]
    fn touch_cancel_all_ends_every_finger_and_empties_the_id_map() {
        let mut m = GestureAndDragManager::new();
        for id in 0..3u64 {
            m.touch_down(
                id,
                pos(id as f32 * 10.0, 0.0),
                ts(id),
                WindowPosition::Uninitialized,
                pos(id as f32 * 10.0, 0.0),
            );
        }
        m.touch_cancel_all();
        assert!(m.touch_sessions.is_empty());
        assert!(m.input_sessions.iter().all(|s| s.ended));
        assert!(!m.touch_move(1, pos(0.0, 0.0), ts(9), pos(0.0, 0.0)));
    }
    #[test]
    fn touch_moves_after_clear_all_sessions_are_dropped_not_resurrected() {
        let mut m = GestureAndDragManager::new();
        m.touch_down(
            1,
            pos(0.0, 0.0),
            ts(0),
            WindowPosition::Uninitialized,
            pos(0.0, 0.0),
        );
        m.clear_all_sessions();
        // The id->session map still holds a dangling entry, but the by-id
        // lookup finds no session, so nothing is recorded and nothing panics.
        assert!(!m.touch_move(1, pos(5.0, 5.0), ts(1), pos(5.0, 5.0)));
        assert_eq!(m.session_count(), 0);
    }
    #[test]
    fn record_sample_for_session_rejects_unknown_and_ended_sessions() {
        let mut m = GestureAndDragManager::new();
        assert!(!m.record_sample_for_session(u64::MAX, pos(0.0, 0.0), ts(0), pos(0.0, 0.0)));
        let id = m.start_input_session(
            pos(0.0, 0.0),
            ts(0),
            0x01,
            WindowPosition::Uninitialized,
            pos(0.0, 0.0),
        );
        assert!(m.record_sample_for_session(id, pos(1.0, 0.0), ts(1), pos(1.0, 0.0)));
        assert!(!m.record_sample_for_session(0, pos(1.0, 0.0), ts(1), pos(1.0, 0.0)));
        m.end_current_session();
        assert!(!m.record_sample_for_session(id, pos(2.0, 0.0), ts(2), pos(2.0, 0.0)));
        assert_eq!(m.input_sessions[0].samples.len(), 2);
    }
    #[test]
    fn record_sample_for_session_is_also_bounded_by_max_samples() {
        let mut m = GestureAndDragManager::new();
        m.touch_down(
            1,
            pos(0.0, 0.0),
            ts(0),
            WindowPosition::Uninitialized,
            pos(0.0, 0.0),
        );
        for i in 1..=(MAX_SAMPLES_PER_SESSION as u64 + 150) {
            assert!(m.touch_move(1, pos(i as f32, 0.0), ts(i), pos(i as f32, 0.0)));
        }
        assert!(m.input_sessions[0].samples.len() <= MAX_SAMPLES_PER_SESSION);
    }
    // ------------------------------------------------- cleanup
    #[test]
    fn clear_old_sessions_reaps_stale_sessions_and_their_long_press_ids() {
        let mut m = GestureAndDragManager::new();
        let old = m.start_input_session(
            pos(0.0, 0.0),
            ts(0),
            0x01,
            WindowPosition::Uninitialized,
            pos(0.0, 0.0),
        );
        m.end_current_session();
        m.mark_long_press_callback_invoked(old);
        let fresh = m.start_input_session(
            pos(0.0, 0.0),
            ts(10_000),
            0x01,
            WindowPosition::Uninitialized,
            pos(0.0, 0.0),
        );
        m.mark_long_press_callback_invoked(fresh);
        assert_eq!(m.debug_counts(), (2, 2));
        // Now is 10_050 ticks: `old` is 10s stale (> 2000ms), `fresh` is 50ms old.
        m.clear_old_sessions(ts(10_050));
        assert_eq!(m.session_count(), 1);
        assert_eq!(m.current_session_id(), Some(fresh));
        assert_eq!(
            m.debug_counts(),
            (1, 1),
            "long-press bookkeeping must not grow unboundedly"
        );
    }
    #[test]
    fn clear_old_sessions_drops_sessions_that_have_no_samples() {
        let mut m = GestureAndDragManager::new();
        m.input_sessions.push(InputSession {
            samples: Vec::new(),
            ended: false,
            session_id: 99,
            window_position_at_start: WindowPosition::Uninitialized,
        });
        m.clear_old_sessions(ts(0));
        assert_eq!(m.session_count(), 0);
    }
    #[test]
    fn clear_old_sessions_with_a_backwards_clock_keeps_everything() {
        let mut m = GestureAndDragManager::new();
        m.start_input_session(
            pos(0.0, 0.0),
            ts(5_000),
            0x01,
            WindowPosition::Uninitialized,
            pos(0.0, 0.0),
        );
        // `now` is *before* the sample: duration_since saturates to 0 => age 0.
        m.clear_old_sessions(ts(0));
        assert_eq!(m.session_count(), 1);
    }
    #[test]
    fn clear_all_sessions_resets_both_counters() {
        let mut m = GestureAndDragManager::new();
        m.start_input_session(
            pos(0.0, 0.0),
            ts(0),
            0x01,
            WindowPosition::Uninitialized,
            pos(0.0, 0.0),
        );
        m.mark_current_long_press_invoked();
        assert_eq!(m.debug_counts(), (1, 1));
        m.clear_all_sessions();
        assert_eq!(m.debug_counts(), (0, 0));
        assert!(m.get_current_session().is_none());
    }
    #[test]
    fn long_press_invocation_marks_are_deduplicated() {
        let mut m = GestureAndDragManager::new();
        for _ in 0..100 {
            m.mark_long_press_callback_invoked(u64::MAX);
            m.mark_long_press_callback_invoked(0);
        }
        assert_eq!(m.debug_counts(), (0, 2));
        // Marking without a session is a no-op, not a panic.
        m.mark_current_long_press_invoked();
        assert_eq!(m.debug_counts(), (0, 2));
    }
    // ------------------------------------------------- drag / long-press detection
    #[test]
    fn detect_drag_fires_exactly_at_the_distance_threshold() {
        // hypot(3, 4) == 5.0 == drag_distance_threshold => `>=` must fire.
        let m = dragging_manager(pos(0.0, 0.0), pos(3.0, 4.0), 20);
        let drag = m.detect_drag().expect("distance == threshold must be a drag");
        assert_eq!(drag.direct_distance, 5.0);
        assert_eq!(drag.total_distance, 5.0);
        assert_eq!(drag.sample_count, 2);
        assert_eq!(drag.duration_ms, 20);
        assert_eq!(drag.session_id, 1);
        assert_eq!(drag.start_position, pos(0.0, 0.0));
        assert_eq!(drag.current_position, pos(3.0, 4.0));
        // Just below the threshold: no drag.
        let m = dragging_manager(pos(0.0, 0.0), pos(4.9, 0.0), 20);
        assert!(m.detect_drag().is_none());
    }
    #[test]
    fn detect_drag_with_nan_movement_returns_none() {
        let m = dragging_manager(pos(0.0, 0.0), pos(f32::NAN, f32::NAN), 20);
        assert!(
            m.detect_drag().is_none(),
            "NaN distance is never >= threshold"
        );
    }
    #[test]
    fn detect_drag_needs_min_samples_for_gesture() {
        let mut m = GestureAndDragManager::new();
        m.start_input_session(
            pos(0.0, 0.0),
            ts(0),
            0x01,
            WindowPosition::Uninitialized,
            pos(500.0, 500.0),
        );
        assert!(m.detect_drag().is_none(), "one sample is not a gesture");
    }
    #[test]
    fn detect_long_press_honours_time_and_distance_thresholds() {
        // Held 500ms (== threshold) without moving => long press.
        let m = dragging_manager(pos(10.0, 10.0), pos(10.0, 10.0), 500);
        let lp = m.detect_long_press().expect("500ms hold is a long press");
        assert_eq!(lp.duration_ms, 500);
        assert_eq!(lp.position, pos(10.0, 10.0));
        assert!(!lp.callback_invoked);
        assert_eq!(lp.session_id, 1);
        // One ms short => not yet.
        let m = dragging_manager(pos(10.0, 10.0), pos(10.0, 10.0), 499);
        assert!(m.detect_long_press().is_none());
        // Long enough but moved too far (> 10px).
        let m = dragging_manager(pos(0.0, 0.0), pos(11.0, 0.0), 800);
        assert!(m.detect_long_press().is_none());
    }
    #[test]
    fn detect_long_press_stops_at_button_up_and_after_being_marked() {
        let mut m = dragging_manager(pos(10.0, 10.0), pos(10.0, 10.0), 600);
        assert!(m.detect_long_press().is_some());
        m.mark_current_long_press_invoked();
        let lp = m.detect_long_press().expect("still held");
        assert!(
            lp.callback_invoked,
            "a marked long press must report callback_invoked"
        );
        m.end_current_session();
        assert!(
            m.detect_long_press().is_none(),
            "a released button cannot be a long press"
        );
    }
    // ------------------------------------------------- click counting
    #[test]
    fn detect_double_click_checks_both_timing_and_distance() {
        let mut m = GestureAndDragManager::new();
        m.input_sessions = vec![
            ended_session(1, vec![sample(10.0, 10.0, 0)]),
            ended_session(2, vec![sample(11.0, 11.0, 100)]),
        ];
        assert!(m.detect_double_click());
        // Too slow (501ms > 500ms).
        m.input_sessions[1].samples[0].timestamp = ts(501);
        assert!(!m.detect_double_click());
        // Fast, but too far apart (>= 5px).
        m.input_sessions[1].samples[0].timestamp = ts(100);
        m.input_sessions[1].samples[0].position = pos(100.0, 10.0);
        assert!(!m.detect_double_click());
        // Fast and close, but the second click is still held down.
        m.input_sessions[1].samples[0].position = pos(11.0, 11.0);
        m.input_sessions[1].ended = false;
        assert!(!m.detect_double_click());
    }
    #[test]
    fn detect_double_click_needs_two_sessions() {
        let mut m = GestureAndDragManager::new();
        m.input_sessions = vec![ended_session(1, vec![sample(0.0, 0.0, 0)])];
        assert!(!m.detect_double_click());
    }
    #[test]
    fn detect_click_count_counts_up_to_three_and_stops_at_the_first_gap() {
        let mut m = GestureAndDragManager::new();
        // Three ended clicks, each 100ms apart at (nearly) the same point.
        m.input_sessions = vec![
            ended_session(1, vec![sample(10.0, 10.0, 0)]),
            ended_session(2, vec![sample(10.0, 11.0, 100)]),
            ended_session(3, vec![sample(11.0, 10.0, 200)]),
        ];
        assert_eq!(m.detect_click_count(), 3);
        // Break the middle gap in *time*: only the newest pair counts.
        m.input_sessions[2].samples[0].timestamp = ts(900);
        assert_eq!(m.detect_click_count(), 1);
        // A backwards clock does NOT break the chain: duration_since saturates
        // to 0, which reads as "no gap at all" => the click still counts.
        m.input_sessions[2].samples[0].timestamp = ts(200);
        m.input_sessions[0].samples[0].timestamp = ts(u64::MAX);
        assert_eq!(m.detect_click_count(), 3);
        // Break the oldest gap in *distance*.
        m.input_sessions[0].samples[0].timestamp = ts(0);
        m.input_sessions[0].samples[0].position = pos(500.0, 500.0);
        assert_eq!(m.detect_click_count(), 2);
    }
    #[test]
    fn detect_click_count_ignores_live_sessions_and_defaults_to_one() {
        let mut m = GestureAndDragManager::new();
        // Only a live (un-ended) session => nothing to count => 1.
        m.start_input_session(
            pos(0.0, 0.0),
            ts(0),
            0x01,
            WindowPosition::Uninitialized,
            pos(0.0, 0.0),
        );
        assert_eq!(m.detect_click_count(), 1);
        assert_eq!(GestureAndDragManager::new().detect_click_count(), 1);
    }
    #[test]
    fn detect_click_count_with_empty_sample_vec_does_not_panic() {
        let mut m = GestureAndDragManager::new();
        m.input_sessions = vec![
            ended_session(1, Vec::new()),
            ended_session(2, vec![sample(0.0, 0.0, 10)]),
        ];
        assert_eq!(m.detect_click_count(), 1);
        assert!(!m.detect_double_click());
    }
    // ------------------------------------------------- direction / velocity / swipe
    #[test]
    fn drag_direction_is_deterministic_for_stationary_and_nan_input() {
        // No movement at all: dx == dy == 0 => documented fallback is Up.
        let m = dragging_manager(pos(5.0, 5.0), pos(5.0, 5.0), 10);
        assert_eq!(m.get_drag_direction(), Some(GestureDirection::Up));
        // NaN deltas compare false everywhere => same deterministic fallback.
        let m = dragging_manager(pos(0.0, 0.0), pos(f32::NAN, f32::NAN), 10);
        assert_eq!(m.get_drag_direction(), Some(GestureDirection::Up));
    }
    #[test]
    fn drag_direction_picks_the_dominant_axis() {
        let cases = [
            (pos(100.0, 1.0), GestureDirection::Right),
            (pos(-100.0, 1.0), GestureDirection::Left),
            (pos(1.0, 100.0), GestureDirection::Down),
            (pos(1.0, -100.0), GestureDirection::Up),
            // Perfect diagonal: |dx| > |dy| is false => vertical wins.
            (pos(50.0, 50.0), GestureDirection::Down),
        ];
        for (to, expected) in cases {
            let m = dragging_manager(pos(0.0, 0.0), to, 10);
            assert_eq!(
                m.get_drag_direction(),
                Some(expected),
                "drag to ({}, {})",
                to.x,
                to.y
            );
        }
    }
    #[test]
    fn gesture_velocity_returns_none_instead_of_dividing_by_zero() {
        // Two samples with the SAME timestamp => duration 0 => no velocity.
        let m = dragging_manager(pos(0.0, 0.0), pos(100.0, 0.0), 0);
        assert!(m.get_gesture_velocity().is_none());
        assert!(!m.is_swipe());
        assert!(m.detect_swipe_direction().is_none());
        // A single sample is not enough either.
        let mut m = GestureAndDragManager::new();
        m.start_input_session(
            pos(0.0, 0.0),
            ts(0),
            0x01,
            WindowPosition::Uninitialized,
            pos(0.0, 0.0),
        );
        assert!(m.get_gesture_velocity().is_none());
    }
    #[test]
    fn swipe_needs_velocity_above_the_configured_threshold() {
        // 60px in 100ms == 600 px/s > 500 px/s.
        let fast = dragging_manager(pos(0.0, 0.0), pos(60.0, 0.0), 100);
        assert!(fast.get_gesture_velocity().unwrap() > 500.0);
        assert!(fast.is_swipe());
        assert_eq!(
            fast.detect_swipe_direction(),
            Some(GestureDirection::Right)
        );
        // 40px in 100ms == 400 px/s < 500 px/s.
        let slow = dragging_manager(pos(0.0, 0.0), pos(0.0, -40.0), 100);
        assert!(!slow.is_swipe());
        assert!(slow.detect_swipe_direction().is_none());
    }
    #[test]
    fn gesture_velocity_with_infinite_travel_saturates_to_infinity() {
        let m = dragging_manager(pos(-f32::MAX, 0.0), pos(f32::MAX, 0.0), 1);
        let v = m.get_gesture_velocity().expect("two samples, 1ms apart");
        assert!(v.is_infinite(), "expected saturation to +inf, got {v}");
        assert!(m.is_swipe());
    }
    // ------------------------------------------------- pinch / rotation
    #[test]
    fn pinch_and_rotation_ignore_sequential_mouse_sessions() {
        // Click, release, then press-and-drag: two sessions, but the first is
        // ended — this must NOT be read as a two-finger gesture.
        let mut m = GestureAndDragManager::new();
        m.start_input_session(
            pos(0.0, 0.0),
            ts(0),
            0x01,
            WindowPosition::Uninitialized,
            pos(0.0, 0.0),
        );
        m.end_current_session();
        m.start_input_session(
            pos(200.0, 0.0),
            ts(10),
            0x01,
            WindowPosition::Uninitialized,
            pos(200.0, 0.0),
        );
        m.record_input_sample(pos(400.0, 0.0), ts(20), 0x01, pos(400.0, 0.0));
        assert_eq!(m.session_count(), 2);
        assert!(m.detect_pinch().is_none(), "an ended session is not a finger");
        assert!(m.detect_rotation().is_none());
    }
    #[test]
    fn pinch_returns_none_when_the_fingers_start_on_top_of_each_other() {
        let mut m = GestureAndDragManager::new();
        m.touch_down(
            1,
            pos(100.0, 100.0),
            ts(0),
            WindowPosition::Uninitialized,
            pos(100.0, 100.0),
        );
        m.touch_down(
            2,
            pos(100.5, 100.0),
            ts(1),
            WindowPosition::Uninitialized,
            pos(100.5, 100.0),
        );
        // initial_distance 0.5 < 1.0 => division guard returns None.
        m.touch_move(1, pos(0.0, 100.0), ts(2), pos(0.0, 100.0));
        assert!(m.detect_pinch().is_none());
    }
    #[test]
    fn pinch_below_the_scale_threshold_is_not_reported() {
        let mut m = GestureAndDragManager::new();
        m.touch_down(
            1,
            pos(100.0, 100.0),
            ts(0),
            WindowPosition::Uninitialized,
            pos(100.0, 100.0),
        );
        m.touch_down(
            2,
            pos(200.0, 100.0),
            ts(1),
            WindowPosition::Uninitialized,
            pos(200.0, 100.0),
        );
        // 100px -> 105px is a 5% change; the threshold is 10%.
        m.touch_move(2, pos(205.0, 100.0), ts(2), pos(205.0, 100.0));
        assert!(m.detect_pinch().is_none());
    }
    #[test]
    fn pinch_in_reports_a_scale_below_one() {
        let mut m = GestureAndDragManager::new();
        m.touch_down(
            1,
            pos(0.0, 0.0),
            ts(0),
            WindowPosition::Uninitialized,
            pos(0.0, 0.0),
        );
        m.touch_down(
            2,
            pos(200.0, 0.0),
            ts(1),
            WindowPosition::Uninitialized,
            pos(200.0, 0.0),
        );
        m.touch_move(1, pos(50.0, 0.0), ts(10), pos(50.0, 0.0));
        m.touch_move(2, pos(150.0, 0.0), ts(11), pos(150.0, 0.0));
        let p = m.detect_pinch().expect("200px -> 100px is a pinch in");
        assert_eq!(p.initial_distance, 200.0);
        assert_eq!(p.current_distance, 100.0);
        assert_eq!(p.scale, 0.5);
        assert_eq!(p.center, pos(100.0, 0.0));
        assert_eq!(p.duration_ms, 10);
    }
    #[test]
    fn pinch_with_infinite_coordinates_saturates_instead_of_panicking() {
        let mut m = GestureAndDragManager::new();
        m.touch_down(
            1,
            pos(0.0, 0.0),
            ts(0),
            WindowPosition::Uninitialized,
            pos(0.0, 0.0),
        );
        m.touch_down(
            2,
            pos(10.0, 0.0),
            ts(1),
            WindowPosition::Uninitialized,
            pos(10.0, 0.0),
        );
        // The spread overflows f32: MAX - (-MAX) == +inf.
        m.touch_move(1, pos(-f32::MAX, 0.0), ts(2), pos(-f32::MAX, 0.0));
        m.touch_move(2, pos(f32::MAX, 0.0), ts(3), pos(f32::MAX, 0.0));
        let p = m.detect_pinch().expect("an overflowing spread is still a pinch");
        assert!(
            !p.scale.is_finite(),
            "expected a saturated (non-finite) scale, got {}",
            p.scale
        );
        assert!(!p.scale.is_nan());
    }
    #[test]
    fn pinch_and_rotation_with_nan_coordinates_never_panic() {
        let mut m = GestureAndDragManager::new();
        m.touch_down(
            1,
            pos(f32::NAN, f32::NAN),
            ts(0),
            WindowPosition::Uninitialized,
            pos(f32::NAN, f32::NAN),
        );
        m.touch_down(
            2,
            pos(200.0, 100.0),
            ts(1),
            WindowPosition::Uninitialized,
            pos(200.0, 100.0),
        );
        // Whatever the detectors decide, they must not produce a *finite*
        // (i.e. plausible-looking but garbage) scale or angle from NaN input.
        assert!(m.detect_pinch().is_none_or(|p| !p.scale.is_finite()));
        assert!(m
            .detect_rotation()
            .is_none_or(|r| !r.angle_radians.is_finite()));
    }
    #[test]
    fn rotation_normalisation_terminates_for_extreme_coordinates() {
        // The angle-wrap `while` loops must not spin: atan2 is bounded to
        // [-PI, PI], so angle_diff can never be infinite.
        let mut m = GestureAndDragManager::new();
        m.touch_down(
            1,
            pos(-f32::MAX, -f32::MAX),
            ts(0),
            WindowPosition::Uninitialized,
            pos(0.0, 0.0),
        );
        m.touch_down(
            2,
            pos(f32::MAX, f32::MAX),
            ts(1),
            WindowPosition::Uninitialized,
            pos(0.0, 0.0),
        );
        m.touch_move(2, pos(-f32::MAX, f32::MAX), ts(2), pos(0.0, 0.0));
        let r = m.detect_rotation();
        assert!(r.is_none_or(|r| r.angle_radians.abs() <= core::f32::consts::PI + 1.0e-4));
    }
    #[test]
    fn rotation_reports_the_signed_angle_between_the_two_fingers() {
        let mut m = GestureAndDragManager::new();
        m.touch_down(
            1,
            pos(0.0, 0.0),
            ts(0),
            WindowPosition::Uninitialized,
            pos(0.0, 0.0),
        );
        m.touch_down(
            2,
            pos(10.0, 0.0),
            ts(1),
            WindowPosition::Uninitialized,
            pos(10.0, 0.0),
        );
        // Finger 2 swings from +x (angle 0) to +y (angle PI/2) around finger 1.
        m.touch_move(2, pos(0.0, 10.0), ts(50), pos(0.0, 10.0));
        let r = m.detect_rotation().expect("a quarter turn is a rotation");
        assert!(
            (r.angle_radians - core::f32::consts::FRAC_PI_2).abs() < 1.0e-4,
            "expected ~PI/2, got {}",
            r.angle_radians
        );
        assert_eq!(r.center, pos(0.0, 5.0));
    }
    #[test]
    fn rotation_below_the_angle_threshold_is_not_reported() {
        let mut m = GestureAndDragManager::new();
        m.touch_down(
            1,
            pos(0.0, 0.0),
            ts(0),
            WindowPosition::Uninitialized,
            pos(0.0, 0.0),
        );
        m.touch_down(
            2,
            pos(1000.0, 0.0),
            ts(1),
            WindowPosition::Uninitialized,
            pos(1000.0, 0.0),
        );
        // ~0.05 rad, under the 0.1 rad threshold.
        m.touch_move(2, pos(1000.0, 50.0), ts(2), pos(1000.0, 50.0));
        assert!(m.detect_rotation().is_none());
    }
    // ------------------------------------------------- native gesture override
    #[test]
    fn injected_native_gestures_win_over_the_in_process_detector() {
        let mut m = GestureAndDragManager::new();
        m.inject_native_gesture(NativeGestureEvent::DoubleClick);
        assert!(m.detect_double_click(), "no sessions, but the OS said so");
        m.clear_native_gesture();
        assert!(!m.detect_double_click());
        let lp = DetectedLongPress {
            position: pos(3.0, 4.0),
            duration_ms: u64::MAX,
            callback_invoked: true,
            session_id: u64::MAX,
        };
        m.inject_native_gesture(NativeGestureEvent::LongPress(lp));
        assert_eq!(m.detect_long_press(), Some(lp));
        m.inject_native_gesture(NativeGestureEvent::Swipe(GestureDirection::Left));
        assert_eq!(m.detect_swipe_direction(), Some(GestureDirection::Left));
        assert!(
            !m.is_swipe(),
            "is_swipe() is velocity-only and ignores the native override"
        );
        let pinch = DetectedPinch {
            scale: f32::INFINITY,
            center: pos(0.0, 0.0),
            initial_distance: 0.0,
            current_distance: f32::NAN,
            duration_ms: 0,
        };
        m.inject_native_gesture(NativeGestureEvent::Pinch(pinch));
        let got = m.detect_pinch().expect("native pinch is passed through");
        assert!(got.scale.is_infinite());
        let rot = DetectedRotation {
            angle_radians: -core::f32::consts::PI,
            center: pos(1.0, 1.0),
            duration_ms: 7,
        };
        m.inject_native_gesture(NativeGestureEvent::Rotation(rot));
        assert_eq!(m.detect_rotation(), Some(rot));
        m.clear_native_gesture();
        assert!(m.detect_long_press().is_none());
        assert!(m.detect_pinch().is_none());
        assert!(m.detect_rotation().is_none());
        assert!(m.detect_swipe_direction().is_none());
    }
    // ------------------------------------------------- pen / pad state
    #[test]
    fn pen_state_stores_extremes_verbatim_and_tracks_the_previous_state() {
        let mut m = GestureAndDragManager::new();
        m.update_pen_state(
            pos(1.0, 2.0),
            f32::NAN,
            (f32::INFINITY, f32::NEG_INFINITY),
            true,
            true,
            true,
            u64::MAX,
        );
        assert!(m.pen_event_pending);
        assert!(m.get_previous_pen_state().is_none());
        let pen = *m.get_pen_state().expect("pen state was just set");
        assert!(pen.pressure.is_nan());
        assert!(pen.tilt.x_tilt.is_infinite());
        assert!(pen.tilt.y_tilt.is_infinite());
        assert!(pen.in_contact && pen.is_eraser && pen.barrel_button_pressed);
        assert_eq!(pen.device_id, u64::MAX);
        // The short form must zero the extended axes.
        assert_eq!(pen.tangential_pressure, 0.0);
        assert_eq!(pen.barrel_roll_rad, 0.0);
        assert_eq!(pen.tool_id, 0);
        m.clear_pen_event_pending();
        assert!(!m.pen_event_pending);
        m.update_pen_state_full(
            pos(0.0, 0.0),
            1.0,
            (0.0, 0.0),
            false,
            false,
            false,
            0,
            f32::NAN,
            -f32::MAX,
            u32::MAX,
        );
        assert!(m.pen_event_pending);
        let prev = *m.get_previous_pen_state().expect("previous pen state kept");
        assert_eq!(prev.device_id, u64::MAX);
        let now = *m.get_pen_state().unwrap();
        assert!(now.tangential_pressure.is_nan());
        assert_eq!(now.barrel_roll_rad, -f32::MAX);
        assert_eq!(now.tool_id, u32::MAX);
        m.clear_pen_state();
        assert!(m.get_pen_state().is_none());
        assert_eq!(m.get_previous_pen_state().map(|p| p.tool_id), Some(u32::MAX));
        assert!(m.pen_event_pending);
        // Clearing twice must not panic and must not resurrect a state.
        m.clear_pen_state();
        assert!(m.get_pen_state().is_none());
        assert!(m.get_previous_pen_state().is_none());
    }
    #[test]
    fn pad_state_round_trips_and_clears() {
        let mut m = GestureAndDragManager::new();
        assert!(m.get_pad_state().is_none());
        m.update_pad_state(WacomPadState {
            express_keys: 0b1010,
            touch_ring: f32::NAN,
            touch_ring_active: true,
            device_id: u64::MAX,
        });
        let pad = *m.get_pad_state().expect("pad state was just set");
        assert!(!pad.express_key(0));
        assert!(pad.express_key(1));
        assert!(!pad.express_key(2));
        assert!(pad.express_key(3));
        assert!(pad.touch_ring.is_nan());
        assert_eq!(pad.device_id, u64::MAX);
        m.clear_pad_state();
        assert!(m.get_pad_state().is_none());
        m.clear_pad_state();
        assert!(m.get_pad_state().is_none());
    }
    // ------------------------------------------------- drag deltas
    #[test]
    fn drag_deltas_use_window_local_and_screen_coordinates_independently() {
        let mut m = GestureAndDragManager::new();
        m.start_input_session(
            pos(10.0, 10.0),
            ts(0),
            0x01,
            WindowPosition::Initialized(PhysicalPositionI32::new(100, 100)),
            pos(110.0, 110.0),
        );
        // One sample: totals exist, but there is no *incremental* delta yet.
        assert_eq!(m.get_drag_delta(), Some((0.0, 0.0)));
        assert_eq!(m.get_drag_delta_screen(), Some((0.0, 0.0)));
        assert!(m.get_drag_delta_screen_incremental().is_none());
        m.record_input_sample(pos(15.0, 10.0), ts(10), 0x01, pos(120.0, 130.0));
        m.record_input_sample(pos(20.0, 10.0), ts(20), 0x01, pos(125.0, 132.0));
        assert_eq!(m.get_drag_delta(), Some((10.0, 0.0)));
        assert_eq!(m.get_drag_delta_screen(), Some((15.0, 22.0)));
        assert_eq!(m.get_drag_delta_screen_incremental(), Some((5.0, 2.0)));
        assert_eq!(
            m.get_window_position_at_session_start(),
            Some(WindowPosition::Initialized(PhysicalPositionI32::new(
                100, 100
            )))
        );
        assert_eq!(m.get_current_mouse_position(), Some(pos(20.0, 10.0)));
    }
    #[test]
    fn drag_deltas_at_f32_extremes_stay_finite_or_saturate() {
        let m = dragging_manager(pos(-f32::MAX, -f32::MAX), pos(f32::MAX, f32::MAX), 5);
        let (dx, dy) = m.get_drag_delta().expect("two samples");
        assert!(dx.is_infinite() && dy.is_infinite());
        let (sx, sy) = m.get_drag_delta_screen().expect("two samples");
        assert!(sx.is_infinite() && sy.is_infinite());
    }
    // ------------------------------------------------- unified drag context
    #[test]
    fn activating_a_node_drag_without_a_detected_drag_is_a_no_op() {
        let mut m = GestureAndDragManager::new();
        // No session at all.
        m.activate_node_drag(DomId::ROOT_ID, NodeId::new(1), DragData::new(), None);
        assert!(!m.is_dragging());
        // A session that has not moved far enough to be a drag.
        let mut m = dragging_manager(pos(0.0, 0.0), pos(1.0, 1.0), 10);
        m.activate_node_drag(DomId::ROOT_ID, NodeId::new(1), DragData::new(), None);
        assert!(!m.is_node_drag_active());
        m.activate_window_drag(WindowPosition::Uninitialized, None);
        assert!(!m.is_window_dragging());
    }
    #[test]
    fn node_drag_context_tracks_its_own_node_and_drop_target() {
        let mut m = dragging_manager(pos(0.0, 0.0), pos(100.0, 0.0), 10);
        let mut data = DragData::new();
        data.set_text("payload");
        m.activate_node_drag(DomId::ROOT_ID, NodeId::new(4), data, None);
        assert!(m.is_dragging());
        assert!(m.is_node_drag_active());
        assert!(m.is_node_dragging(DomId::ROOT_ID, NodeId::new(4)));
        assert!(!m.is_node_dragging(DomId::ROOT_ID, NodeId::new(5)));
        assert!(!m.is_node_dragging(DomId { inner: 7 }, NodeId::new(4)));
        assert!(!m.is_window_dragging());
        assert!(!m.is_file_dropping());
        assert!(!m.is_text_selection_dragging());
        assert!(!m.is_scrollbar_dragging());
        assert!(m.get_window_drag_delta().is_none());
        assert!(m.get_scrollbar_scroll_offset().is_none());
        m.update_active_drag_positions(pos(42.0, -7.0));
        assert_eq!(
            m.get_drag_context().unwrap().current_position(),
            pos(42.0, -7.0)
        );
        m.update_drop_target(Some(azul_core::dom::DomNodeId {
            dom: DomId::ROOT_ID,
            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(9))),
        }));
        let nd = m
            .get_drag_context()
            .and_then(DragContext::as_node_drag)
            .expect("node drag");
        assert_eq!(
            nd.current_drop_target
                .into_option()
                .and_then(|t| t.node.into_crate_internal()),
            Some(NodeId::new(9))
        );
        assert_eq!(nd.drag_data.get_data("text/plain"), Some(&b"payload"[..]));
        // Clearing the target back to None must work too.
        m.update_drop_target(None);
        assert!(m
            .get_drag_context()
            .and_then(DragContext::as_node_drag)
            .unwrap()
            .current_drop_target
            .into_option()
            .is_none());
        assert!(m.is_node_drag_active());
        let ctx = m.end_drag().expect("the drag context is returned");
        assert_eq!(ctx.session_id, 1);
        assert!(!m.is_dragging());
        assert!(m.end_drag().is_none());
    }
    #[test]
    fn drop_target_updates_without_a_drag_do_not_panic() {
        let mut m = GestureAndDragManager::new();
        m.update_drop_target(None);
        m.update_active_drag_positions(pos(f32::NAN, f32::INFINITY));
        m.cancel_drag();
        assert!(!m.is_dragging());
        assert!(m.get_drag_context_mut().is_none());
    }
    #[test]
    fn a_text_selection_drag_is_not_a_node_drag() {
        let mut m = GestureAndDragManager::new();
        m.active_drag = Some(DragContext::text_selection(
            DomId::ROOT_ID,
            NodeId::new(2),
            pos(0.0, 0.0),
            11,
        ));
        assert!(m.is_text_selection_dragging());
        assert!(!m.is_node_drag_active());
        // update_drop_target must leave a text-selection drag untouched.
        m.update_drop_target(None);
        assert!(m.is_text_selection_dragging());
        m.cancel_drag();
        assert!(!m.is_dragging());
        assert!(!m.is_text_selection_dragging());
    }
    // ------------------------------------------------- window drag maths
    fn window_dragging_manager(initial: WindowPosition) -> GestureAndDragManager {
        let mut m = dragging_manager(pos(0.0, 0.0), pos(100.0, 0.0), 10);
        m.activate_window_drag(initial, None);
        assert!(m.is_window_dragging());
        m
    }
    #[test]
    fn window_drag_delta_needs_an_initialized_window_position() {
        let m = window_dragging_manager(WindowPosition::Uninitialized);
        assert!(m.get_window_drag_delta().is_none());
        assert!(m.get_window_position_from_drag().is_none());
    }
    #[test]
    fn window_drag_delta_is_measured_from_the_drag_start() {
        let mut m =
            window_dragging_manager(WindowPosition::Initialized(PhysicalPositionI32::new(10, 20)));
        m.update_active_drag_positions(pos(30.5, -20.9));
        // start_position is the drag's start (0,0) => delta truncates toward zero.
        assert_eq!(m.get_window_drag_delta(), Some((30, -20)));
        assert_eq!(
            m.get_window_position_from_drag(),
            Some(WindowPosition::Initialized(PhysicalPositionI32::new(40, 0)))
        );
    }
    #[test]
    fn window_drag_delta_saturates_the_float_to_int_cast() {
        let mut m =
            window_dragging_manager(WindowPosition::Initialized(PhysicalPositionI32::new(0, 0)));
        m.update_active_drag_positions(pos(f32::MAX, -f32::MAX));
        assert_eq!(
            m.get_window_drag_delta(),
            Some((i32::MAX, i32::MIN)),
            "float->int casts must saturate, not wrap or trap"
        );
        assert_eq!(
            m.get_window_position_from_drag(),
            Some(WindowPosition::Initialized(PhysicalPositionI32::new(
                i32::MAX,
                i32::MIN
            )))
        );
    }
    #[test]
    fn window_drag_delta_with_nan_position_is_zero_not_a_trap() {
        let mut m =
            window_dragging_manager(WindowPosition::Initialized(PhysicalPositionI32::new(3, 4)));
        m.update_active_drag_positions(pos(f32::NAN, f32::NAN));
        // `NaN as i32` is defined as 0 in Rust.
        assert_eq!(m.get_window_drag_delta(), Some((0, 0)));
        assert_eq!(
            m.get_window_position_from_drag(),
            Some(WindowPosition::Initialized(PhysicalPositionI32::new(3, 4)))
        );
    }
    #[test]
    fn window_position_from_drag_at_the_i32_extremes_does_not_overflow() {
        // i32::MAX window origin dragged fully negative: MAX + MIN == -1.
        let mut m = window_dragging_manager(WindowPosition::Initialized(
            PhysicalPositionI32::new(i32::MAX, i32::MAX),
        ));
        m.update_active_drag_positions(pos(-f32::MAX, -f32::MAX));
        assert_eq!(
            m.get_window_position_from_drag(),
            Some(WindowPosition::Initialized(PhysicalPositionI32::new(-1, -1)))
        );
    }
    // ------------------------------------------------- scrollbar drag maths
    fn scrollbar_manager(
        start_offset: f32,
        track: f32,
        content: f32,
        viewport: f32,
    ) -> GestureAndDragManager {
        let mut m = GestureAndDragManager::new();
        m.active_drag = Some(DragContext::scrollbar_thumb(
            DomId::ROOT_ID,
            NodeId::new(1),
            ScrollbarAxis::Vertical,
            pos(0.0, 0.0),
            start_offset,
            track,
            content,
            viewport,
            1,
        ));
        m
    }
    #[test]
    fn scrollbar_offset_scales_the_mouse_delta_and_clamps_to_the_range() {
        let mut m = scrollbar_manager(0.0, 100.0, 1000.0, 100.0);
        assert!(m.is_scrollbar_dragging());
        assert_eq!(m.get_scrollbar_scroll_offset(), Some(0.0));
        // thumb = 10px, scrollable track = 90px, scrollable range = 900px.
        m.update_active_drag_positions(pos(0.0, 45.0));
        let half = m.get_scrollbar_scroll_offset().expect("scrollbar drag");
        assert!((half - 450.0).abs() < 0.5, "expected ~450, got {half}");
        // Way past the end of the track: clamped to the scrollable range.
        m.update_active_drag_positions(pos(0.0, 1.0e9));
        assert_eq!(m.get_scrollbar_scroll_offset(), Some(900.0));
        // Dragged backwards past the start: clamped to 0.
        m.update_active_drag_positions(pos(0.0, -1.0e9));
        assert_eq!(m.get_scrollbar_scroll_offset(), Some(0.0));
    }
    #[test]
    fn scrollbar_offset_with_nothing_to_scroll_returns_the_start_offset() {
        // content <= viewport => scrollable range <= 0.
        let mut m = scrollbar_manager(42.0, 100.0, 50.0, 100.0);
        m.update_active_drag_positions(pos(0.0, 500.0));
        assert_eq!(m.get_scrollbar_scroll_offset(), Some(42.0));
        // A zero-length track cannot be scrolled either.
        let mut m = scrollbar_manager(7.0, 0.0, 1000.0, 100.0);
        m.update_active_drag_positions(pos(0.0, 500.0));
        assert_eq!(m.get_scrollbar_scroll_offset(), Some(7.0));
    }
    #[test]
    fn scrollbar_offset_with_a_nan_mouse_position_does_not_panic() {
        let mut m = scrollbar_manager(0.0, 100.0, 1000.0, 100.0);
        m.update_active_drag_positions(pos(f32::NAN, f32::NAN));
        let v = m.get_scrollbar_scroll_offset();
        assert!(
            v.is_some_and(f32::is_nan),
            "a NaN mouse position must propagate as NaN, not panic: {v:?}"
        );
    }
    // ------------------------------------------------- event ids
    #[cfg(feature = "std")]
    #[test]
    fn allocate_event_id_is_strictly_monotonic() {
        let a = allocate_event_id();
        let b = allocate_event_id();
        let c = allocate_event_id();
        assert!(a < b && b < c, "ids must increase: {a} {b} {c}");
    }
    #[cfg(not(feature = "std"))]
    #[test]
    fn allocate_event_id_is_zero_without_std() {
        assert_eq!(allocate_event_id(), 0);
    }
    #[cfg(feature = "std")]
    #[test]
    fn recorded_samples_get_distinct_event_ids() {
        let mut m = GestureAndDragManager::new();
        m.start_input_session(
            pos(0.0, 0.0),
            ts(0),
            0x01,
            WindowPosition::Uninitialized,
            pos(0.0, 0.0),
        );
        m.record_input_sample(pos(1.0, 0.0), ts(1), 0x01, pos(1.0, 0.0));
        let s = m.get_current_session().unwrap();
        assert_ne!(s.samples[0].event_id, s.samples[1].event_id);
        assert!(s.samples[0].event_id < s.samples[1].event_id);
    }
}