Skip to main content

azul_layout/managers/
gesture.rs

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