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 = self.base_event(
249                    InteractionEventKind::MouseLeave,
250                    pointer_kind,
251                    modifiers,
252                );
253                event.target = Some(prev.target.clone());
254                // The new target becomes the related_target on the leave event.
255                event.related_target = curr_target.cloned();
256                self.events.push(event);
257            }
258
259            // Clear previous hover feature state.
260            if self.config.auto_hover_state {
261                if let Some(prev_id) = self.hovered.take() {
262                    map.set_feature_state_property(
263                        &prev_id.source_id,
264                        &prev_id.feature_id,
265                        "hover",
266                        PropertyValue::Bool(false),
267                    );
268                }
269            }
270
271            // MouseEnter for the new target.
272            if let Some(curr) = &current {
273                let mut event = self.base_event(
274                    InteractionEventKind::MouseEnter,
275                    pointer_kind,
276                    modifiers,
277                );
278                event.target = Some(curr.target.clone());
279                event.hit = Some(curr.hit.clone());
280                // The old target becomes the related_target on the enter event.
281                event.related_target = prev_target.cloned();
282                self.events.push(event);
283
284                // Set hover feature state on the new target.
285                if self.config.auto_hover_state {
286                    if let Some(id) = feature_state_id_from_target(&curr.target) {
287                        map.set_feature_state_property(
288                            &id.source_id,
289                            &id.feature_id,
290                            "hover",
291                            PropertyValue::Bool(true),
292                        );
293                        self.hovered = Some(id);
294                    }
295                }
296            }
297        }
298
299        // Always emit MouseMove with the current target attached.
300        let mut move_event =
301            self.base_event(InteractionEventKind::MouseMove, pointer_kind, modifiers);
302        if let Some(curr) = &current {
303            move_event.target = Some(curr.target.clone());
304            move_event.hit = Some(curr.hit.clone());
305        }
306        self.events.push(move_event);
307
308        // Store the snapshot for next-frame diffing.
309        self.prev_hit = current;
310    }
311
312    /// Feed a pointer-down event.
313    ///
314    /// Records the press position and timestamp for later click-vs-drag
315    /// classification. Emits a `MouseDown` event.
316    ///
317    /// `time` is a monotonic timestamp in seconds provided by the host.
318    pub fn update_pointer_down(
319        &mut self,
320        map: &MapState,
321        x: f64,
322        y: f64,
323        time: f64,
324        button: InteractionButton,
325        pointer_kind: PointerKind,
326        modifiers: InteractionModifiers,
327    ) {
328        let _ = map; // reserved for future use (e.g. immediate down-pick)
329        self.current_time = time;
330        self.pointer_down_pos = Some(ScreenPoint::new(x, y));
331        self.pointer_down_time = Some(time);
332        self.dragging = false;
333
334        let mut event = self.base_event(
335            InteractionEventKind::MouseDown,
336            pointer_kind,
337            modifiers,
338        );
339        event.button = Some(button);
340        // Attach the current hover target if available.
341        if let Some(snapshot) = &self.prev_hit {
342            event.target = Some(snapshot.target.clone());
343            event.hit = Some(snapshot.hit.clone());
344        }
345        self.events.push(event);
346    }
347
348    /// Feed a pointer-up event.
349    ///
350    /// Emits `MouseUp` unconditionally. If the pointer did not exceed the drag
351    /// threshold, also emits `Click`. If two `Click`s occur within the
352    /// double-click window, also emits `DoubleClick`.
353    ///
354    /// When `auto_select_state` is enabled and a `Click` is produced, the
355    /// manager updates the `"selected"` feature-state property.
356    ///
357    /// `time` is a monotonic timestamp in seconds provided by the host.
358    pub fn update_pointer_up(
359        &mut self,
360        map: &mut MapState,
361        x: f64,
362        y: f64,
363        time: f64,
364        button: InteractionButton,
365        pointer_kind: PointerKind,
366        modifiers: InteractionModifiers,
367    ) {
368        self.current_time = time;
369        self.cursor = ScreenPoint::new(x, y);
370
371        // Always emit MouseUp.
372        let mut up_event = self.base_event(
373            InteractionEventKind::MouseUp,
374            pointer_kind,
375            modifiers,
376        );
377        up_event.button = Some(button);
378        if let Some(snapshot) = &self.prev_hit {
379            up_event.target = Some(snapshot.target.clone());
380            up_event.hit = Some(snapshot.hit.clone());
381        }
382        self.events.push(up_event);
383
384        // Determine if this is a click (not a drag).
385        let is_click = !self.dragging;
386
387        if is_click {
388            // Emit Click.
389            let mut click_event = self.base_event(
390                InteractionEventKind::Click,
391                pointer_kind,
392                modifiers,
393            );
394            click_event.button = Some(button);
395            if let Some(snapshot) = &self.prev_hit {
396                click_event.target = Some(snapshot.target.clone());
397                click_event.hit = Some(snapshot.hit.clone());
398            }
399            self.events.push(click_event);
400
401            // Update selection state if configured.
402            if self.config.auto_select_state {
403                self.update_selection(map);
404            }
405
406            // Check double-click timing.
407            if let Some(prev_click) = self.last_click_time {
408                if (time - prev_click) <= self.config.double_click_window_secs {
409                    let mut dbl_event = self.base_event(
410                        InteractionEventKind::DoubleClick,
411                        pointer_kind,
412                        modifiers,
413                    );
414                    dbl_event.button = Some(button);
415                    if let Some(snapshot) = &self.prev_hit {
416                        dbl_event.target = Some(snapshot.target.clone());
417                        dbl_event.hit = Some(snapshot.hit.clone());
418                    }
419                    self.events.push(dbl_event);
420                    // Reset so a third click doesn't chain.
421                    self.last_click_time = None;
422                } else {
423                    self.last_click_time = Some(time);
424                }
425            } else {
426                self.last_click_time = Some(time);
427            }
428        }
429
430        // Clear pointer-down tracking.
431        self.pointer_down_pos = None;
432        self.pointer_down_time = None;
433        self.dragging = false;
434    }
435
436    /// Notify the manager that the pointer has left the map viewport.
437    ///
438    /// Emits `MouseLeave` for the current hover target and clears all
439    /// transient hover state.
440    pub fn update_pointer_leave(
441        &mut self,
442        map: &mut MapState,
443        pointer_kind: PointerKind,
444        modifiers: InteractionModifiers,
445    ) {
446        // Emit leave for the current target if any.
447        if let Some(prev) = self.prev_hit.take() {
448            let mut event = self.base_event(
449                InteractionEventKind::MouseLeave,
450                pointer_kind,
451                modifiers,
452            );
453            event.target = Some(prev.target);
454            self.events.push(event);
455        }
456
457        // Clear hover feature state.
458        if self.config.auto_hover_state {
459            if let Some(prev_id) = self.hovered.take() {
460                map.set_feature_state_property(
461                    &prev_id.source_id,
462                    &prev_id.feature_id,
463                    "hover",
464                    PropertyValue::Bool(false),
465                );
466            }
467        }
468
469        self.pointer_down_pos = None;
470        self.pointer_down_time = None;
471        self.dragging = false;
472    }
473
474    // -----------------------------------------------------------------------
475    // Event consumption
476    // -----------------------------------------------------------------------
477
478    /// Drain and return all pending interaction events.
479    ///
480    /// This is the primary output interface. The host calls this once per
481    /// frame after feeding all input events and iterates the returned
482    /// `Vec` to dispatch application-level handlers.
483    pub fn drain_events(&mut self) -> Vec<InteractionEvent> {
484        std::mem::take(&mut self.events)
485    }
486
487    /// Number of pending events not yet drained.
488    pub fn pending_event_count(&self) -> usize {
489        self.events.len()
490    }
491
492    // -----------------------------------------------------------------------
493    // Internal helpers
494    // -----------------------------------------------------------------------
495
496    /// Run a pick query at `(x, y)` respecting the interactive-layer filter.
497    fn pick_at(&self, map: &MapState, x: f64, y: f64) -> PickResult {
498        let mut options = PickOptions::new();
499        options.tolerance_meters = self.config.tolerance_meters;
500        options.limit = 1;
501        map.pick(PickQuery::screen(x, y), options)
502    }
503
504    /// Build a base event with the current cursor, projection, and query coord.
505    fn base_event(
506        &self,
507        kind: InteractionEventKind,
508        pointer_kind: PointerKind,
509        modifiers: InteractionModifiers,
510    ) -> InteractionEvent {
511        InteractionEvent::new(kind, pointer_kind, self.cursor)
512            .with_modifiers(modifiers)
513    }
514
515    /// Update single-select state: clear previous, apply current hover target.
516    fn update_selection(&mut self, map: &mut MapState) {
517        // Clear previous selection.
518        if let Some(prev_id) = self.selected.take() {
519            map.set_feature_state_property(
520                &prev_id.source_id,
521                &prev_id.feature_id,
522                "selected",
523                PropertyValue::Bool(false),
524            );
525        }
526
527        // Apply selection to the current hover target.
528        if let Some(snapshot) = &self.prev_hit {
529            if let Some(id) = feature_state_id_from_target(&snapshot.target) {
530                map.set_feature_state_property(
531                    &id.source_id,
532                    &id.feature_id,
533                    "selected",
534                    PropertyValue::Bool(true),
535                );
536                self.selected = Some(id);
537            }
538        }
539    }
540}
541
542impl Default for InteractionManager {
543    fn default() -> Self {
544        Self::new()
545    }
546}
547
548// ---------------------------------------------------------------------------
549// Free helpers
550// ---------------------------------------------------------------------------
551
552/// Extract the top hit from a pick result and wrap it in a snapshot.
553fn top_hit_snapshot(result: &PickResult) -> Option<HitSnapshot> {
554    result.first().map(|hit| HitSnapshot {
555        target: InteractionTarget::from_pick_hit(hit),
556        hit: hit.clone(),
557    })
558}
559
560/// Derive a [`FeatureStateId`] from an interaction target when the target
561/// has both source_id and feature_id.
562fn feature_state_id_from_target(target: &InteractionTarget) -> Option<FeatureStateId> {
563    match (&target.source_id, &target.feature_id) {
564        (Some(source_id), Some(feature_id)) => {
565            Some(FeatureStateId::new(source_id.clone(), feature_id.clone()))
566        }
567        _ => None,
568    }
569}
570
571// ---------------------------------------------------------------------------
572// Tests
573// ---------------------------------------------------------------------------
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use crate::geometry::{Feature, FeatureCollection, Geometry, Point};
579    use crate::layers::{VectorLayer, VectorStyle};
580    use rustial_math::GeoCoord;
581
582    /// Helper: build a MapState with a single point feature at the viewport
583    /// center so hover queries produce a hit.
584    fn map_with_point_at_center() -> MapState {
585        let mut state = MapState::new();
586        state.set_viewport(800, 600);
587        let target = GeoCoord::from_lat_lon(0.0, 0.0);
588        state.set_camera_target(target);
589        state.set_camera_distance(500.0);
590
591        let fc = FeatureCollection {
592            features: vec![Feature {
593                geometry: Geometry::Point(Point { coord: target }),
594                properties: Default::default(),
595            }],
596        };
597        let vl = VectorLayer::new("points", fc, VectorStyle::default());
598        state.push_layer(Box::new(vl));
599        state.update();
600        state
601    }
602
603    // -- enter / leave transitions ------------------------------------------
604
605    #[test]
606    fn pointer_move_over_feature_emits_enter_then_move() {
607        let mut map = map_with_point_at_center();
608        let mut mgr = InteractionManager::new();
609
610        // Move to viewport center where the point feature lives.
611        mgr.update_pointer_move(
612            &mut map,
613            400.0,
614            300.0,
615            0.0,
616            PointerKind::Mouse,
617            InteractionModifiers::default(),
618        );
619
620        let events = mgr.drain_events();
621        // First event should be MouseEnter, second should be MouseMove.
622        assert!(events.len() >= 2, "expected at least enter + move, got {}", events.len());
623        assert_eq!(events[0].kind, InteractionEventKind::MouseEnter);
624        assert_eq!(events[1].kind, InteractionEventKind::MouseMove);
625        assert!(events[0].target.is_some(), "enter event should have a target");
626    }
627
628    #[test]
629    fn pointer_move_away_emits_leave() {
630        let mut map = map_with_point_at_center();
631        let mut mgr = InteractionManager::new();
632
633        // First, hover over the feature.
634        mgr.update_pointer_move(
635            &mut map, 400.0, 300.0, 0.0,
636            PointerKind::Mouse, InteractionModifiers::default(),
637        );
638        mgr.drain_events(); // consume enter + move
639
640        // Then move far away so there is no hit.
641        mgr.update_pointer_move(
642            &mut map, 10.0, 10.0, 0.1,
643            PointerKind::Mouse, InteractionModifiers::default(),
644        );
645
646        let events = mgr.drain_events();
647        // Should contain a MouseLeave followed by a MouseMove.
648        let kinds: Vec<_> = events.iter().map(|e| e.kind).collect();
649        assert!(
650            kinds.contains(&InteractionEventKind::MouseLeave),
651            "expected MouseLeave, got {:?}",
652            kinds
653        );
654    }
655
656    // -- click vs drag suppression ------------------------------------------
657
658    #[test]
659    fn click_within_threshold_emits_click() {
660        let mut map = map_with_point_at_center();
661        let mut mgr = InteractionManager::new();
662
663        // Hover first so there is a target.
664        mgr.update_pointer_move(
665            &mut map, 400.0, 300.0, 0.0,
666            PointerKind::Mouse, InteractionModifiers::default(),
667        );
668        mgr.drain_events();
669
670        // Pointer down.
671        mgr.update_pointer_down(
672            &map, 400.0, 300.0, 1.0,
673            InteractionButton::Primary,
674            PointerKind::Mouse, InteractionModifiers::default(),
675        );
676        mgr.drain_events();
677
678        // Pointer up at approximately the same position (within threshold).
679        mgr.update_pointer_up(
680            &mut map, 401.0, 300.0, 1.1,
681            InteractionButton::Primary,
682            PointerKind::Mouse, InteractionModifiers::default(),
683        );
684
685        let events = mgr.drain_events();
686        let kinds: Vec<_> = events.iter().map(|e| e.kind).collect();
687        assert!(kinds.contains(&InteractionEventKind::MouseUp));
688        assert!(
689            kinds.contains(&InteractionEventKind::Click),
690            "expected Click, got {:?}",
691            kinds
692        );
693    }
694
695    #[test]
696    fn drag_beyond_threshold_suppresses_click() {
697        let mut map = map_with_point_at_center();
698        let mut mgr = InteractionManager::new();
699
700        mgr.update_pointer_move(
701            &mut map, 400.0, 300.0, 0.0,
702            PointerKind::Mouse, InteractionModifiers::default(),
703        );
704        mgr.drain_events();
705
706        // Pointer down.
707        mgr.update_pointer_down(
708            &map, 400.0, 300.0, 1.0,
709            InteractionButton::Primary,
710            PointerKind::Mouse, InteractionModifiers::default(),
711        );
712        mgr.drain_events();
713
714        // Move far enough to exceed the default 5px drag threshold.
715        mgr.update_pointer_move(
716            &mut map, 420.0, 320.0, 1.05,
717            PointerKind::Mouse, InteractionModifiers::default(),
718        );
719        mgr.drain_events();
720
721        // Pointer up.
722        mgr.update_pointer_up(
723            &mut map, 420.0, 320.0, 1.1,
724            InteractionButton::Primary,
725            PointerKind::Mouse, InteractionModifiers::default(),
726        );
727
728        let events = mgr.drain_events();
729        let kinds: Vec<_> = events.iter().map(|e| e.kind).collect();
730        assert!(kinds.contains(&InteractionEventKind::MouseUp));
731        assert!(
732            !kinds.contains(&InteractionEventKind::Click),
733            "Click should be suppressed after drag, got {:?}",
734            kinds
735        );
736    }
737
738    // -- double-click timing ------------------------------------------------
739
740    #[test]
741    fn two_clicks_within_window_emit_double_click() {
742        let mut map = map_with_point_at_center();
743        let mut mgr = InteractionManager::new();
744
745        mgr.update_pointer_move(
746            &mut map, 400.0, 300.0, 0.0,
747            PointerKind::Mouse, InteractionModifiers::default(),
748        );
749        mgr.drain_events();
750
751        // First click.
752        mgr.update_pointer_down(
753            &map, 400.0, 300.0, 1.0,
754            InteractionButton::Primary,
755            PointerKind::Mouse, InteractionModifiers::default(),
756        );
757        mgr.update_pointer_up(
758            &mut map, 400.0, 300.0, 1.05,
759            InteractionButton::Primary,
760            PointerKind::Mouse, InteractionModifiers::default(),
761        );
762        mgr.drain_events(); // consume first click events
763
764        // Second click within the 300ms window.
765        mgr.update_pointer_down(
766            &map, 400.0, 300.0, 1.2,
767            InteractionButton::Primary,
768            PointerKind::Mouse, InteractionModifiers::default(),
769        );
770        mgr.update_pointer_up(
771            &mut map, 400.0, 300.0, 1.25,
772            InteractionButton::Primary,
773            PointerKind::Mouse, InteractionModifiers::default(),
774        );
775
776        let events = mgr.drain_events();
777        let kinds: Vec<_> = events.iter().map(|e| e.kind).collect();
778        assert!(
779            kinds.contains(&InteractionEventKind::DoubleClick),
780            "expected DoubleClick, got {:?}",
781            kinds
782        );
783    }
784
785    #[test]
786    fn two_clicks_outside_window_do_not_emit_double_click() {
787        let mut map = map_with_point_at_center();
788        let mut mgr = InteractionManager::new();
789
790        mgr.update_pointer_move(
791            &mut map, 400.0, 300.0, 0.0,
792            PointerKind::Mouse, InteractionModifiers::default(),
793        );
794        mgr.drain_events();
795
796        // First click.
797        mgr.update_pointer_down(
798            &map, 400.0, 300.0, 1.0,
799            InteractionButton::Primary,
800            PointerKind::Mouse, InteractionModifiers::default(),
801        );
802        mgr.update_pointer_up(
803            &mut map, 400.0, 300.0, 1.05,
804            InteractionButton::Primary,
805            PointerKind::Mouse, InteractionModifiers::default(),
806        );
807        mgr.drain_events();
808
809        // Second click outside the 300ms window.
810        mgr.update_pointer_down(
811            &map, 400.0, 300.0, 2.0,
812            InteractionButton::Primary,
813            PointerKind::Mouse, InteractionModifiers::default(),
814        );
815        mgr.update_pointer_up(
816            &mut map, 400.0, 300.0, 2.05,
817            InteractionButton::Primary,
818            PointerKind::Mouse, InteractionModifiers::default(),
819        );
820
821        let events = mgr.drain_events();
822        let kinds: Vec<_> = events.iter().map(|e| e.kind).collect();
823        assert!(
824            !kinds.contains(&InteractionEventKind::DoubleClick),
825            "DoubleClick should NOT fire outside timing window, got {:?}",
826            kinds
827        );
828    }
829
830    // -- drain clears queue -------------------------------------------------
831
832    #[test]
833    fn drain_events_clears_pending_queue() {
834        let mut map = map_with_point_at_center();
835        let mut mgr = InteractionManager::new();
836
837        mgr.update_pointer_move(
838            &mut map, 400.0, 300.0, 0.0,
839            PointerKind::Mouse, InteractionModifiers::default(),
840        );
841        assert!(mgr.pending_event_count() > 0);
842
843        let events = mgr.drain_events();
844        assert!(!events.is_empty());
845        assert_eq!(mgr.pending_event_count(), 0);
846    }
847
848    // -- pointer-leave clears hover -----------------------------------------
849
850    #[test]
851    fn pointer_leave_emits_leave_and_clears_hover() {
852        let mut map = map_with_point_at_center();
853        let mut mgr = InteractionManager::new();
854
855        // Hover over feature.
856        mgr.update_pointer_move(
857            &mut map, 400.0, 300.0, 0.0,
858            PointerKind::Mouse, InteractionModifiers::default(),
859        );
860        mgr.drain_events();
861        assert!(mgr.hovered().is_some() || mgr.prev_hit.is_some());
862
863        // Pointer leaves the viewport.
864        mgr.update_pointer_leave(
865            &mut map,
866            PointerKind::Mouse, InteractionModifiers::default(),
867        );
868
869        let events = mgr.drain_events();
870        let kinds: Vec<_> = events.iter().map(|e| e.kind).collect();
871        assert!(
872            kinds.contains(&InteractionEventKind::MouseLeave),
873            "expected MouseLeave on pointer leave, got {:?}",
874            kinds
875        );
876        assert!(mgr.prev_hit.is_none());
877    }
878
879    // -- auto hover state ---------------------------------------------------
880
881    #[test]
882    fn auto_hover_state_sets_and_clears_feature_state() {
883        let mut map = map_with_point_at_center();
884        let mut mgr = InteractionManager::new();
885
886        // Move over feature - should set hover=true.
887        mgr.update_pointer_move(
888            &mut map, 400.0, 300.0, 0.0,
889            PointerKind::Mouse, InteractionModifiers::default(),
890        );
891        mgr.drain_events();
892
893        if let Some(id) = mgr.hovered() {
894            let state = map.feature_state(&id.source_id, &id.feature_id);
895            let hover_val = state
896                .and_then(|s| s.get("hover"))
897                .and_then(|v| v.as_bool());
898            assert_eq!(hover_val, Some(true));
899        }
900
901        // Move away - should set hover=false.
902        mgr.update_pointer_move(
903            &mut map, 10.0, 10.0, 0.1,
904            PointerKind::Mouse, InteractionModifiers::default(),
905        );
906        mgr.drain_events();
907
908        // Hovered should now be None.
909        assert!(mgr.hovered().is_none());
910    }
911}