Skip to main content

rustial_engine/
interaction_manager.rs

1//! Engine-owned interaction manager that automates hover, leave, click, and
2//! selection lifecycle bookkeeping.
3//!
4//! The [`InteractionManager`] sits between the host event loop and the engine's
5//! pick/query APIs. Each frame the host feeds raw pointer events
6//! (`update_pointer_move`, `update_pointer_down`, `update_pointer_up`,
7//! `update_pointer_leave`) and the manager emits high-level
8//! [`InteractionEvent`]s via [`drain_events`](InteractionManager::drain_events).
9//!
10//! # Design goals
11//!
12//! - **No host-side hover bookkeeping.** Enter/leave transitions are diffed
13//!   internally so the host never tracks previous targets.
14//! - **Click-vs-drag suppression.** Pointer-down followed by movement beyond a
15//!   configurable pixel threshold is treated as a drag - no `Click` event is
16//!   emitted.
17//! - **Double-click timing.** Two `Click` events within a configurable time
18//!   window produce a `DoubleClick`.
19//! - **Layer filtering.** An optional set of interactive layer ids restricts
20//!   which layers participate in automatic hover/select queries.
21//! - **Backend-neutral.** Works identically in Bevy and pure WGPU integrations.
22
23use crate::geometry::PropertyValue;
24use crate::interaction::{
25    InteractionButton, InteractionEvent, InteractionEventKind, InteractionModifiers,
26    InteractionTarget, PointerKind, ScreenPoint,
27};
28use crate::picking::{PickHit, PickOptions, PickQuery, PickResult};
29use crate::query::FeatureStateId;
30use crate::MapState;
31
32// ---------------------------------------------------------------------------
33// Configuration
34// ---------------------------------------------------------------------------
35
36/// Tuning knobs for the interaction manager.
37///
38/// All thresholds use sensible defaults matching typical web-map UX
39/// expectations. Override individual fields after construction if your host
40/// application needs different tolerances.
41#[derive(Debug, Clone)]
42pub struct InteractionConfig {
43    /// Maximum pixel distance between pointer-down and pointer-up that still
44    /// counts as a click rather than a drag. Default: `5.0`.
45    pub drag_threshold_px: f64,
46
47    /// Maximum elapsed seconds between two consecutive clicks that produces a
48    /// `DoubleClick` event. Default: `0.3` (300 ms).
49    pub double_click_window_secs: f64,
50
51    /// When `true`, the manager automatically sets a `"hover"` boolean
52    /// property in feature state for the currently hovered feature and clears
53    /// it when the cursor leaves. Default: `true`.
54    pub auto_hover_state: bool,
55
56    /// When `true`, the manager automatically sets a `"selected"` boolean
57    /// property in feature state for clicked features and clears the previous
58    /// selection. Default: `false`.
59    pub auto_select_state: bool,
60
61    /// Optional set of interactive layer ids. When non-empty, only layers
62    /// whose style-layer id appears in this list participate in automatic
63    /// hover/select queries. An empty list means all layers participate.
64    pub interactive_layers: Vec<String>,
65
66    /// Hit tolerance in meters passed to pick queries. `0.0` means the
67    /// engine uses its default (8 pixels worth of meters).
68    pub tolerance_meters: f64,
69}
70
71impl Default for InteractionConfig {
72    fn default() -> Self {
73        Self {
74            drag_threshold_px: 5.0,
75            double_click_window_secs: 0.3,
76            auto_hover_state: true,
77            auto_select_state: false,
78            interactive_layers: Vec::new(),
79            tolerance_meters: 0.0,
80        }
81    }
82}
83
84// ---------------------------------------------------------------------------
85// Snapshot of the most recent pick result at a given screen position.
86// ---------------------------------------------------------------------------
87
88/// Internal snapshot used by the manager to diff consecutive frames.
89#[derive(Debug, Clone)]
90struct HitSnapshot {
91    /// Top-priority hit from the most recent pick.
92    hit: PickHit,
93    /// Derived interaction target identity.
94    target: InteractionTarget,
95}
96
97// ---------------------------------------------------------------------------
98// InteractionManager
99// ---------------------------------------------------------------------------
100
101/// Stateful runtime that interprets raw pointer input and emits high-level
102/// [`InteractionEvent`]s.
103///
104/// # Lifecycle
105///
106/// 1. Create with [`InteractionManager::new`] or
107///    [`InteractionManager::with_config`].
108/// 2. Each frame, feed input via `update_pointer_move`, `update_pointer_down`,
109///    `update_pointer_up`, or `update_pointer_leave`.
110/// 3. Call [`drain_events`](Self::drain_events) to consume emitted events.
111///
112/// The manager **borrows** `&mut MapState` during each update call so it can
113/// run pick queries and (optionally) mutate feature state.
114#[derive(Debug)]
115pub struct InteractionManager {
116    /// Configuration knobs.
117    config: InteractionConfig,
118
119    /// Current logical cursor position.
120    cursor: ScreenPoint,
121
122    /// Snapshot of the top hit from the previous pointer-move query.
123    prev_hit: Option<HitSnapshot>,
124
125    /// Currently hovered feature identity (source + feature id).
126    hovered: Option<FeatureStateId>,
127
128    /// Currently selected feature identity (source + feature id).
129    selected: Option<FeatureStateId>,
130
131    /// Pointer-down screen position for drag detection.
132    pointer_down_pos: Option<ScreenPoint>,
133
134    /// Timestamp (seconds) of the most recent pointer-down.
135    pointer_down_time: Option<f64>,
136
137    /// Whether the current pointer sequence has exceeded the drag threshold.
138    dragging: bool,
139
140    /// Timestamp (seconds) of the most recent `Click` emission, used for
141    /// double-click detection.
142    last_click_time: Option<f64>,
143
144    /// Monotonic time source fed by the host (seconds since an arbitrary
145    /// epoch). Updated on each input call.
146    current_time: f64,
147
148    /// Pending event queue drained by the host each frame.
149    events: Vec<InteractionEvent>,
150}
151
152impl InteractionManager {
153    /// Create a new interaction manager with default configuration.
154    pub fn new() -> Self {
155        Self::with_config(InteractionConfig::default())
156    }
157
158    /// Create a new interaction manager with custom configuration.
159    pub fn with_config(config: InteractionConfig) -> Self {
160        Self {
161            config,
162            cursor: ScreenPoint::default(),
163            prev_hit: None,
164            hovered: None,
165            selected: None,
166            pointer_down_pos: None,
167            pointer_down_time: None,
168            dragging: false,
169            last_click_time: None,
170            current_time: 0.0,
171            events: Vec::new(),
172        }
173    }
174
175    /// Read-only access to the current configuration.
176    pub fn config(&self) -> &InteractionConfig {
177        &self.config
178    }
179
180    /// Mutable access to the configuration.
181    pub fn config_mut(&mut self) -> &mut InteractionConfig {
182        &mut self.config
183    }
184
185    /// The feature identity that is currently hovered, if any.
186    pub fn hovered(&self) -> Option<&FeatureStateId> {
187        self.hovered.as_ref()
188    }
189
190    /// The feature identity that is currently selected, if any.
191    pub fn selected(&self) -> Option<&FeatureStateId> {
192        self.selected.as_ref()
193    }
194
195    /// Current logical cursor position.
196    pub fn cursor(&self) -> ScreenPoint {
197        self.cursor
198    }
199
200    /// Whether the current pointer sequence is being treated as a drag.
201    pub fn is_dragging(&self) -> bool {
202        self.dragging
203    }
204
205    // -----------------------------------------------------------------------
206    // Input entry points
207    // -----------------------------------------------------------------------
208
209    /// Feed a pointer-move event.
210    ///
211    /// The manager runs a pick query at `(x, y)`, diffs the result against
212    /// the previous frame, and emits `MouseLeave` / `MouseEnter` transitions
213    /// as needed. A `MouseMove` event is always emitted.
214    ///
215    /// `time` is a monotonic timestamp in seconds provided by the host.
216    pub fn update_pointer_move(
217        &mut self,
218        map: &mut MapState,
219        x: f64,
220        y: f64,
221        time: f64,
222        pointer_kind: PointerKind,
223        modifiers: InteractionModifiers,
224    ) {
225        self.current_time = time;
226        self.cursor = ScreenPoint::new(x, y);
227
228        // Check drag threshold if a pointer-down is active.
229        if let Some(down_pos) = self.pointer_down_pos {
230            let dx = x - down_pos.x;
231            let dy = y - down_pos.y;
232            if (dx * dx + dy * dy).sqrt() > self.config.drag_threshold_px {
233                self.dragging = true;
234            }
235        }
236
237        // Run a pick query at the cursor.
238        let result = self.pick_at(map, x, y);
239        let current = top_hit_snapshot(&result);
240
241        // Diff targets: emit leave for old, enter for new.
242        let prev_target = self.prev_hit.as_ref().map(|s| &s.target);
243        let curr_target = current.as_ref().map(|s| &s.target);
244
245        if prev_target != curr_target {
246            // MouseLeave for the previous target.
247            if let Some(prev) = &self.prev_hit {
248                let mut event =
249                    self.base_event(InteractionEventKind::MouseLeave, pointer_kind, modifiers);
250                event.target = Some(prev.target.clone());
251                // The new target becomes the related_target on the leave event.
252                event.related_target = curr_target.cloned();
253                self.events.push(event);
254            }
255
256            // Clear previous hover feature state.
257            if self.config.auto_hover_state {
258                if let Some(prev_id) = self.hovered.take() {
259                    map.set_feature_state_property(
260                        &prev_id.source_id,
261                        &prev_id.feature_id,
262                        "hover",
263                        PropertyValue::Bool(false),
264                    );
265                }
266            }
267
268            // MouseEnter for the new target.
269            if let Some(curr) = &current {
270                let mut event =
271                    self.base_event(InteractionEventKind::MouseEnter, pointer_kind, modifiers);
272                event.target = Some(curr.target.clone());
273                event.hit = Some(curr.hit.clone());
274                // The old target becomes the related_target on the enter event.
275                event.related_target = prev_target.cloned();
276                self.events.push(event);
277
278                // Set hover feature state on the new target.
279                if self.config.auto_hover_state {
280                    if let Some(id) = feature_state_id_from_target(&curr.target) {
281                        map.set_feature_state_property(
282                            &id.source_id,
283                            &id.feature_id,
284                            "hover",
285                            PropertyValue::Bool(true),
286                        );
287                        self.hovered = Some(id);
288                    }
289                }
290            }
291        }
292
293        // Always emit MouseMove with the current target attached.
294        let mut move_event =
295            self.base_event(InteractionEventKind::MouseMove, pointer_kind, modifiers);
296        if let Some(curr) = &current {
297            move_event.target = Some(curr.target.clone());
298            move_event.hit = Some(curr.hit.clone());
299        }
300        self.events.push(move_event);
301
302        // Store the snapshot for next-frame diffing.
303        self.prev_hit = current;
304    }
305
306    /// Feed a pointer-down event.
307    ///
308    /// Records the press position and timestamp for later click-vs-drag
309    /// classification. Emits a `MouseDown` event.
310    ///
311    /// `time` is a monotonic timestamp in seconds provided by the host.
312    #[allow(clippy::too_many_arguments)]
313    pub fn update_pointer_down(
314        &mut self,
315        map: &MapState,
316        x: f64,
317        y: f64,
318        time: f64,
319        button: InteractionButton,
320        pointer_kind: PointerKind,
321        modifiers: InteractionModifiers,
322    ) {
323        let _ = map; // reserved for future use (e.g. immediate down-pick)
324        self.current_time = time;
325        self.pointer_down_pos = Some(ScreenPoint::new(x, y));
326        self.pointer_down_time = Some(time);
327        self.dragging = false;
328
329        let mut event = self.base_event(InteractionEventKind::MouseDown, pointer_kind, modifiers);
330        event.button = Some(button);
331        // Attach the current hover target if available.
332        if let Some(snapshot) = &self.prev_hit {
333            event.target = Some(snapshot.target.clone());
334            event.hit = Some(snapshot.hit.clone());
335        }
336        self.events.push(event);
337    }
338
339    /// Feed a pointer-up event.
340    ///
341    /// Emits `MouseUp` unconditionally. If the pointer did not exceed the drag
342    /// threshold, also emits `Click`. If two `Click`s occur within the
343    /// double-click window, also emits `DoubleClick`.
344    ///
345    /// When `auto_select_state` is enabled and a `Click` is produced, the
346    /// manager updates the `"selected"` feature-state property.
347    ///
348    /// `time` is a monotonic timestamp in seconds provided by the host.
349    #[allow(clippy::too_many_arguments)]
350    pub fn update_pointer_up(
351        &mut self,
352        map: &mut MapState,
353        x: f64,
354        y: f64,
355        time: f64,
356        button: InteractionButton,
357        pointer_kind: PointerKind,
358        modifiers: InteractionModifiers,
359    ) {
360        self.current_time = time;
361        self.cursor = ScreenPoint::new(x, y);
362
363        // Always emit MouseUp.
364        let mut up_event = self.base_event(InteractionEventKind::MouseUp, pointer_kind, modifiers);
365        up_event.button = Some(button);
366        if let Some(snapshot) = &self.prev_hit {
367            up_event.target = Some(snapshot.target.clone());
368            up_event.hit = Some(snapshot.hit.clone());
369        }
370        self.events.push(up_event);
371
372        // Determine if this is a click (not a drag).
373        let is_click = !self.dragging;
374
375        if is_click {
376            // Emit Click.
377            let mut click_event =
378                self.base_event(InteractionEventKind::Click, pointer_kind, modifiers);
379            click_event.button = Some(button);
380            if let Some(snapshot) = &self.prev_hit {
381                click_event.target = Some(snapshot.target.clone());
382                click_event.hit = Some(snapshot.hit.clone());
383            }
384            self.events.push(click_event);
385
386            // Update selection state if configured.
387            if self.config.auto_select_state {
388                self.update_selection(map);
389            }
390
391            // Check double-click timing.
392            if let Some(prev_click) = self.last_click_time {
393                if (time - prev_click) <= self.config.double_click_window_secs {
394                    let mut dbl_event =
395                        self.base_event(InteractionEventKind::DoubleClick, pointer_kind, modifiers);
396                    dbl_event.button = Some(button);
397                    if let Some(snapshot) = &self.prev_hit {
398                        dbl_event.target = Some(snapshot.target.clone());
399                        dbl_event.hit = Some(snapshot.hit.clone());
400                    }
401                    self.events.push(dbl_event);
402                    // Reset so a third click doesn't chain.
403                    self.last_click_time = None;
404                } else {
405                    self.last_click_time = Some(time);
406                }
407            } else {
408                self.last_click_time = Some(time);
409            }
410        }
411
412        // Clear pointer-down tracking.
413        self.pointer_down_pos = None;
414        self.pointer_down_time = None;
415        self.dragging = false;
416    }
417
418    /// Notify the manager that the pointer has left the map viewport.
419    ///
420    /// Emits `MouseLeave` for the current hover target and clears all
421    /// transient hover state.
422    pub fn update_pointer_leave(
423        &mut self,
424        map: &mut MapState,
425        pointer_kind: PointerKind,
426        modifiers: InteractionModifiers,
427    ) {
428        // Emit leave for the current target if any.
429        if let Some(prev) = self.prev_hit.take() {
430            let mut event =
431                self.base_event(InteractionEventKind::MouseLeave, pointer_kind, modifiers);
432            event.target = Some(prev.target);
433            self.events.push(event);
434        }
435
436        // Clear hover feature state.
437        if self.config.auto_hover_state {
438            if let Some(prev_id) = self.hovered.take() {
439                map.set_feature_state_property(
440                    &prev_id.source_id,
441                    &prev_id.feature_id,
442                    "hover",
443                    PropertyValue::Bool(false),
444                );
445            }
446        }
447
448        self.pointer_down_pos = None;
449        self.pointer_down_time = None;
450        self.dragging = false;
451    }
452
453    // -----------------------------------------------------------------------
454    // Event consumption
455    // -----------------------------------------------------------------------
456
457    /// Drain and return all pending interaction events.
458    ///
459    /// This is the primary output interface. The host calls this once per
460    /// frame after feeding all input events and iterates the returned
461    /// `Vec` to dispatch application-level handlers.
462    pub fn drain_events(&mut self) -> Vec<InteractionEvent> {
463        std::mem::take(&mut self.events)
464    }
465
466    /// Number of pending events not yet drained.
467    pub fn pending_event_count(&self) -> usize {
468        self.events.len()
469    }
470
471    // -----------------------------------------------------------------------
472    // Internal helpers
473    // -----------------------------------------------------------------------
474
475    /// Run a pick query at `(x, y)` respecting the interactive-layer filter.
476    fn pick_at(&self, map: &MapState, x: f64, y: f64) -> PickResult {
477        let mut options = PickOptions::new();
478        options.tolerance_meters = self.config.tolerance_meters;
479        options.limit = 1;
480        map.pick(PickQuery::screen(x, y), options)
481    }
482
483    /// Build a base event with the current cursor, projection, and query coord.
484    fn base_event(
485        &self,
486        kind: InteractionEventKind,
487        pointer_kind: PointerKind,
488        modifiers: InteractionModifiers,
489    ) -> InteractionEvent {
490        InteractionEvent::new(kind, pointer_kind, self.cursor).with_modifiers(modifiers)
491    }
492
493    /// Update single-select state: clear previous, apply current hover target.
494    fn update_selection(&mut self, map: &mut MapState) {
495        // Clear previous selection.
496        if let Some(prev_id) = self.selected.take() {
497            map.set_feature_state_property(
498                &prev_id.source_id,
499                &prev_id.feature_id,
500                "selected",
501                PropertyValue::Bool(false),
502            );
503        }
504
505        // Apply selection to the current hover target.
506        if let Some(snapshot) = &self.prev_hit {
507            if let Some(id) = feature_state_id_from_target(&snapshot.target) {
508                map.set_feature_state_property(
509                    &id.source_id,
510                    &id.feature_id,
511                    "selected",
512                    PropertyValue::Bool(true),
513                );
514                self.selected = Some(id);
515            }
516        }
517    }
518}
519
520impl Default for InteractionManager {
521    fn default() -> Self {
522        Self::new()
523    }
524}
525
526// ---------------------------------------------------------------------------
527// Free helpers
528// ---------------------------------------------------------------------------
529
530/// Extract the top hit from a pick result and wrap it in a snapshot.
531fn top_hit_snapshot(result: &PickResult) -> Option<HitSnapshot> {
532    result.first().map(|hit| HitSnapshot {
533        target: InteractionTarget::from_pick_hit(hit),
534        hit: hit.clone(),
535    })
536}
537
538/// Derive a [`FeatureStateId`] from an interaction target when the target
539/// has both source_id and feature_id.
540fn feature_state_id_from_target(target: &InteractionTarget) -> Option<FeatureStateId> {
541    match (&target.source_id, &target.feature_id) {
542        (Some(source_id), Some(feature_id)) => {
543            Some(FeatureStateId::new(source_id.clone(), feature_id.clone()))
544        }
545        _ => None,
546    }
547}
548
549// ---------------------------------------------------------------------------
550// Tests
551// ---------------------------------------------------------------------------
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556    use crate::geometry::{Feature, FeatureCollection, Geometry, Point};
557    use crate::layers::{VectorLayer, VectorStyle};
558    use rustial_math::GeoCoord;
559
560    /// Helper: build a MapState with a single point feature at the viewport
561    /// center so hover queries produce a hit.
562    fn map_with_point_at_center() -> MapState {
563        let mut state = MapState::new();
564        state.set_viewport(800, 600);
565        let target = GeoCoord::from_lat_lon(0.0, 0.0);
566        state.set_camera_target(target);
567        state.set_camera_distance(500.0);
568
569        let fc = FeatureCollection {
570            features: vec![Feature {
571                geometry: Geometry::Point(Point { coord: target }),
572                properties: Default::default(),
573            }],
574        };
575        let vl = VectorLayer::new("points", fc, VectorStyle::default());
576        state.push_layer(Box::new(vl));
577        state.update();
578        state
579    }
580
581    // -- enter / leave transitions ------------------------------------------
582
583    #[test]
584    fn pointer_move_over_feature_emits_enter_then_move() {
585        let mut map = map_with_point_at_center();
586        let mut mgr = InteractionManager::new();
587
588        // Move to viewport center where the point feature lives.
589        mgr.update_pointer_move(
590            &mut map,
591            400.0,
592            300.0,
593            0.0,
594            PointerKind::Mouse,
595            InteractionModifiers::default(),
596        );
597
598        let events = mgr.drain_events();
599        // First event should be MouseEnter, second should be MouseMove.
600        assert!(
601            events.len() >= 2,
602            "expected at least enter + move, got {}",
603            events.len()
604        );
605        assert_eq!(events[0].kind, InteractionEventKind::MouseEnter);
606        assert_eq!(events[1].kind, InteractionEventKind::MouseMove);
607        assert!(
608            events[0].target.is_some(),
609            "enter event should have a target"
610        );
611    }
612
613    #[test]
614    fn pointer_move_away_emits_leave() {
615        let mut map = map_with_point_at_center();
616        let mut mgr = InteractionManager::new();
617
618        // First, hover over the feature.
619        mgr.update_pointer_move(
620            &mut map,
621            400.0,
622            300.0,
623            0.0,
624            PointerKind::Mouse,
625            InteractionModifiers::default(),
626        );
627        mgr.drain_events(); // consume enter + move
628
629        // Then move far away so there is no hit.
630        mgr.update_pointer_move(
631            &mut map,
632            10.0,
633            10.0,
634            0.1,
635            PointerKind::Mouse,
636            InteractionModifiers::default(),
637        );
638
639        let events = mgr.drain_events();
640        // Should contain a MouseLeave followed by a MouseMove.
641        let kinds: Vec<_> = events.iter().map(|e| e.kind).collect();
642        assert!(
643            kinds.contains(&InteractionEventKind::MouseLeave),
644            "expected MouseLeave, got {:?}",
645            kinds
646        );
647    }
648
649    // -- click vs drag suppression ------------------------------------------
650
651    #[test]
652    fn click_within_threshold_emits_click() {
653        let mut map = map_with_point_at_center();
654        let mut mgr = InteractionManager::new();
655
656        // Hover first so there is a target.
657        mgr.update_pointer_move(
658            &mut map,
659            400.0,
660            300.0,
661            0.0,
662            PointerKind::Mouse,
663            InteractionModifiers::default(),
664        );
665        mgr.drain_events();
666
667        // Pointer down.
668        mgr.update_pointer_down(
669            &map,
670            400.0,
671            300.0,
672            1.0,
673            InteractionButton::Primary,
674            PointerKind::Mouse,
675            InteractionModifiers::default(),
676        );
677        mgr.drain_events();
678
679        // Pointer up at approximately the same position (within threshold).
680        mgr.update_pointer_up(
681            &mut map,
682            401.0,
683            300.0,
684            1.1,
685            InteractionButton::Primary,
686            PointerKind::Mouse,
687            InteractionModifiers::default(),
688        );
689
690        let events = mgr.drain_events();
691        let kinds: Vec<_> = events.iter().map(|e| e.kind).collect();
692        assert!(kinds.contains(&InteractionEventKind::MouseUp));
693        assert!(
694            kinds.contains(&InteractionEventKind::Click),
695            "expected Click, got {:?}",
696            kinds
697        );
698    }
699
700    #[test]
701    fn drag_beyond_threshold_suppresses_click() {
702        let mut map = map_with_point_at_center();
703        let mut mgr = InteractionManager::new();
704
705        mgr.update_pointer_move(
706            &mut map,
707            400.0,
708            300.0,
709            0.0,
710            PointerKind::Mouse,
711            InteractionModifiers::default(),
712        );
713        mgr.drain_events();
714
715        // Pointer down.
716        mgr.update_pointer_down(
717            &map,
718            400.0,
719            300.0,
720            1.0,
721            InteractionButton::Primary,
722            PointerKind::Mouse,
723            InteractionModifiers::default(),
724        );
725        mgr.drain_events();
726
727        // Move far enough to exceed the default 5px drag threshold.
728        mgr.update_pointer_move(
729            &mut map,
730            420.0,
731            320.0,
732            1.05,
733            PointerKind::Mouse,
734            InteractionModifiers::default(),
735        );
736        mgr.drain_events();
737
738        // Pointer up.
739        mgr.update_pointer_up(
740            &mut map,
741            420.0,
742            320.0,
743            1.1,
744            InteractionButton::Primary,
745            PointerKind::Mouse,
746            InteractionModifiers::default(),
747        );
748
749        let events = mgr.drain_events();
750        let kinds: Vec<_> = events.iter().map(|e| e.kind).collect();
751        assert!(kinds.contains(&InteractionEventKind::MouseUp));
752        assert!(
753            !kinds.contains(&InteractionEventKind::Click),
754            "Click should be suppressed after drag, got {:?}",
755            kinds
756        );
757    }
758
759    // -- double-click timing ------------------------------------------------
760
761    #[test]
762    fn two_clicks_within_window_emit_double_click() {
763        let mut map = map_with_point_at_center();
764        let mut mgr = InteractionManager::new();
765
766        mgr.update_pointer_move(
767            &mut map,
768            400.0,
769            300.0,
770            0.0,
771            PointerKind::Mouse,
772            InteractionModifiers::default(),
773        );
774        mgr.drain_events();
775
776        // First click.
777        mgr.update_pointer_down(
778            &map,
779            400.0,
780            300.0,
781            1.0,
782            InteractionButton::Primary,
783            PointerKind::Mouse,
784            InteractionModifiers::default(),
785        );
786        mgr.update_pointer_up(
787            &mut map,
788            400.0,
789            300.0,
790            1.05,
791            InteractionButton::Primary,
792            PointerKind::Mouse,
793            InteractionModifiers::default(),
794        );
795        mgr.drain_events(); // consume first click events
796
797        // Second click within the 300ms window.
798        mgr.update_pointer_down(
799            &map,
800            400.0,
801            300.0,
802            1.2,
803            InteractionButton::Primary,
804            PointerKind::Mouse,
805            InteractionModifiers::default(),
806        );
807        mgr.update_pointer_up(
808            &mut map,
809            400.0,
810            300.0,
811            1.25,
812            InteractionButton::Primary,
813            PointerKind::Mouse,
814            InteractionModifiers::default(),
815        );
816
817        let events = mgr.drain_events();
818        let kinds: Vec<_> = events.iter().map(|e| e.kind).collect();
819        assert!(
820            kinds.contains(&InteractionEventKind::DoubleClick),
821            "expected DoubleClick, got {:?}",
822            kinds
823        );
824    }
825
826    #[test]
827    fn two_clicks_outside_window_do_not_emit_double_click() {
828        let mut map = map_with_point_at_center();
829        let mut mgr = InteractionManager::new();
830
831        mgr.update_pointer_move(
832            &mut map,
833            400.0,
834            300.0,
835            0.0,
836            PointerKind::Mouse,
837            InteractionModifiers::default(),
838        );
839        mgr.drain_events();
840
841        // First click.
842        mgr.update_pointer_down(
843            &map,
844            400.0,
845            300.0,
846            1.0,
847            InteractionButton::Primary,
848            PointerKind::Mouse,
849            InteractionModifiers::default(),
850        );
851        mgr.update_pointer_up(
852            &mut map,
853            400.0,
854            300.0,
855            1.05,
856            InteractionButton::Primary,
857            PointerKind::Mouse,
858            InteractionModifiers::default(),
859        );
860        mgr.drain_events();
861
862        // Second click outside the 300ms window.
863        mgr.update_pointer_down(
864            &map,
865            400.0,
866            300.0,
867            2.0,
868            InteractionButton::Primary,
869            PointerKind::Mouse,
870            InteractionModifiers::default(),
871        );
872        mgr.update_pointer_up(
873            &mut map,
874            400.0,
875            300.0,
876            2.05,
877            InteractionButton::Primary,
878            PointerKind::Mouse,
879            InteractionModifiers::default(),
880        );
881
882        let events = mgr.drain_events();
883        let kinds: Vec<_> = events.iter().map(|e| e.kind).collect();
884        assert!(
885            !kinds.contains(&InteractionEventKind::DoubleClick),
886            "DoubleClick should NOT fire outside timing window, got {:?}",
887            kinds
888        );
889    }
890
891    // -- drain clears queue -------------------------------------------------
892
893    #[test]
894    fn drain_events_clears_pending_queue() {
895        let mut map = map_with_point_at_center();
896        let mut mgr = InteractionManager::new();
897
898        mgr.update_pointer_move(
899            &mut map,
900            400.0,
901            300.0,
902            0.0,
903            PointerKind::Mouse,
904            InteractionModifiers::default(),
905        );
906        assert!(mgr.pending_event_count() > 0);
907
908        let events = mgr.drain_events();
909        assert!(!events.is_empty());
910        assert_eq!(mgr.pending_event_count(), 0);
911    }
912
913    // -- pointer-leave clears hover -----------------------------------------
914
915    #[test]
916    fn pointer_leave_emits_leave_and_clears_hover() {
917        let mut map = map_with_point_at_center();
918        let mut mgr = InteractionManager::new();
919
920        // Hover over feature.
921        mgr.update_pointer_move(
922            &mut map,
923            400.0,
924            300.0,
925            0.0,
926            PointerKind::Mouse,
927            InteractionModifiers::default(),
928        );
929        mgr.drain_events();
930        assert!(mgr.hovered().is_some() || mgr.prev_hit.is_some());
931
932        // Pointer leaves the viewport.
933        mgr.update_pointer_leave(
934            &mut map,
935            PointerKind::Mouse,
936            InteractionModifiers::default(),
937        );
938
939        let events = mgr.drain_events();
940        let kinds: Vec<_> = events.iter().map(|e| e.kind).collect();
941        assert!(
942            kinds.contains(&InteractionEventKind::MouseLeave),
943            "expected MouseLeave on pointer leave, got {:?}",
944            kinds
945        );
946        assert!(mgr.prev_hit.is_none());
947    }
948
949    // -- auto hover state ---------------------------------------------------
950
951    #[test]
952    fn auto_hover_state_sets_and_clears_feature_state() {
953        let mut map = map_with_point_at_center();
954        let mut mgr = InteractionManager::new();
955
956        // Move over feature - should set hover=true.
957        mgr.update_pointer_move(
958            &mut map,
959            400.0,
960            300.0,
961            0.0,
962            PointerKind::Mouse,
963            InteractionModifiers::default(),
964        );
965        mgr.drain_events();
966
967        if let Some(id) = mgr.hovered() {
968            let state = map.feature_state(&id.source_id, &id.feature_id);
969            let hover_val = state.and_then(|s| s.get("hover")).and_then(|v| v.as_bool());
970            assert_eq!(hover_val, Some(true));
971        }
972
973        // Move away - should set hover=false.
974        mgr.update_pointer_move(
975            &mut map,
976            10.0,
977            10.0,
978            0.1,
979            PointerKind::Mouse,
980            InteractionModifiers::default(),
981        );
982        mgr.drain_events();
983
984        // Hovered should now be None.
985        assert!(mgr.hovered().is_none());
986    }
987}