Skip to main content

azul_layout/managers/
hover.rs

1//! Hover state management for tracking mouse and touch hover history
2//!
3//! The HoverManager records hit test results for multiple input points
4//! (mouse, touch, pen) over multiple frames to enable gesture detection
5//! (like DragStart) that requires analyzing hover patterns over time
6//! rather than just the current frame.
7
8use std::collections::{BTreeMap, VecDeque};
9
10use crate::hit_test::FullHitTest;
11
12/// Maximum number of frames to keep in hover history
13const MAX_HOVER_HISTORY: usize = 5;
14
15/// Identifier for an input point (mouse, touch, pen, etc.)
16#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
17pub enum InputPointId {
18    /// Mouse cursor
19    Mouse,
20    /// Touch point with unique ID (from TouchEvent.id)
21    Touch(u64),
22}
23
24/// Manages hover state history for all input points
25///
26/// Records hit test results for mouse and touch inputs over multiple frames:
27/// - DragStart detection (requires movement threshold over multiple frames)
28/// - Hover-over event detection
29/// - Multi-touch gesture detection
30/// - Input path analysis
31///
32/// The manager maintains a separate history for each active input point.
33#[derive(Debug, Clone, PartialEq)]
34pub struct HoverManager {
35    /// Hit test history for each input point
36    /// Each point has its own ring buffer of the last N frames
37    hover_histories: BTreeMap<InputPointId, VecDeque<FullHitTest>>,
38}
39
40impl HoverManager {
41    /// Create a new empty HoverManager
42    pub fn new() -> Self {
43        Self {
44            hover_histories: BTreeMap::new(),
45        }
46    }
47
48    /// Push a new hit test result for a specific input point
49    ///
50    /// The most recent result is always at index 0 for that input point.
51    /// If the history is full, the oldest frame is dropped.
52    pub fn push_hit_test(&mut self, input_id: InputPointId, hit_test: FullHitTest) {
53        let history = self
54            .hover_histories
55            .entry(input_id)
56            .or_insert_with(|| VecDeque::with_capacity(MAX_HOVER_HISTORY));
57
58        // Add to front (most recent)
59        history.push_front(hit_test);
60
61        // Remove oldest if we exceed the limit
62        if history.len() > MAX_HOVER_HISTORY {
63            history.pop_back();
64        }
65    }
66
67    /// Remove an input point's history (e.g., when touch ends)
68    pub fn remove_input_point(&mut self, input_id: &InputPointId) {
69        self.hover_histories.remove(input_id);
70    }
71
72    /// Get the most recent hit test result for an input point
73    ///
74    /// Returns None if no hit tests have been recorded for this input point.
75    pub fn get_current(&self, input_id: &InputPointId) -> Option<&FullHitTest> {
76        self.hover_histories
77            .get(input_id)
78            .and_then(|history| history.front())
79    }
80
81    /// Get the most recent mouse cursor hit test (convenience method)
82    pub fn get_current_mouse(&self) -> Option<&FullHitTest> {
83        self.get_current(&InputPointId::Mouse)
84    }
85
86    /// Get the hit test result from N frames ago for an input point
87    // (0 = current frame)
88    ///
89    /// Returns None if the requested frame is not in history.
90    pub fn get_frame(&self, input_id: &InputPointId, frames_ago: usize) -> Option<&FullHitTest> {
91        self.hover_histories
92            .get(input_id)
93            .and_then(|history| history.get(frames_ago))
94    }
95
96    /// Get the entire hover history for an input point (most recent first)
97    pub fn get_history(&self, input_id: &InputPointId) -> Option<&VecDeque<FullHitTest>> {
98        self.hover_histories.get(input_id)
99    }
100
101    /// Get all currently tracked input points
102    pub fn get_active_input_points(&self) -> Vec<InputPointId> {
103        self.hover_histories.keys().copied().collect()
104    }
105
106    /// Get the number of frames in history for an input point
107    pub fn frame_count(&self, input_id: &InputPointId) -> usize {
108        self.hover_histories
109            .get(input_id)
110            .map(|h| h.len())
111            .unwrap_or(0)
112    }
113
114    /// Clear all hover history for all input points
115    pub fn clear(&mut self) {
116        self.hover_histories.clear();
117    }
118
119    /// Clear history for a specific input point
120    pub fn clear_input_point(&mut self, input_id: &InputPointId) {
121        if let Some(history) = self.hover_histories.get_mut(input_id) {
122            history.clear();
123        }
124    }
125
126    /// Check if we have enough frames for gesture detection on an input point
127    ///
128    /// DragStart detection requires analyzing movement over multiple frames.
129    /// This returns true if we have at least 2 frames of history.
130    pub fn has_sufficient_history_for_gestures(&self, input_id: &InputPointId) -> bool {
131        self.frame_count(input_id) >= 2
132    }
133
134    /// Check if any input point has enough history for gesture detection
135    pub fn any_has_sufficient_history_for_gestures(&self) -> bool {
136        self.hover_histories
137            .iter()
138            .any(|(_, history)| history.len() >= 2)
139    }
140
141    /// Get the deepest hovered node from the current mouse hit test.
142    ///
143    /// Returns the NodeId of the most specific (deepest in DOM tree) node
144    /// that the mouse cursor is currently over, or None if not hovering anything.
145    pub fn current_hover_node(&self) -> Option<azul_core::id::NodeId> {
146        let current = self.get_current_mouse()?;
147        let dom_id = azul_core::dom::DomId { inner: 0 };
148        let ht = current.hovered_nodes.get(&dom_id)?;
149        ht.regular_hit_test_nodes.keys().last().copied()
150    }
151
152    /// Get the deepest hovered node from the previous frame's mouse hit test.
153    ///
154    /// Returns the NodeId from one frame ago, or None if not hovering anything
155    /// or no previous frame exists.
156    pub fn previous_hover_node(&self) -> Option<azul_core::id::NodeId> {
157        let history = self.hover_histories.get(&InputPointId::Mouse)?;
158        let previous = history.get(1)?; // index 1 = one frame ago
159        let dom_id = azul_core::dom::DomId { inner: 0 };
160        let ht = previous.hovered_nodes.get(&dom_id)?;
161        ht.regular_hit_test_nodes.keys().last().copied()
162    }
163
164    /// Remap NodeIds in all hover histories after DOM reconciliation.
165    ///
166    /// When the DOM is regenerated, NodeIds can change. This method updates
167    /// all stored NodeIds in hover histories using the old→new mapping from
168    /// reconciliation. Nodes not found in the map are removed from hit tests.
169    pub fn remap_node_ids(
170        &mut self,
171        dom_id: azul_core::dom::DomId,
172        node_id_map: &std::collections::BTreeMap<azul_core::id::NodeId, azul_core::id::NodeId>,
173    ) {
174        for history in self.hover_histories.values_mut() {
175            for hit_test in history.iter_mut() {
176                if let Some(ht) = hit_test.hovered_nodes.get_mut(&dom_id) {
177                    // Remap regular_hit_test_nodes
178                    let old_regular: Vec<_> = ht.regular_hit_test_nodes.keys().cloned().collect();
179                    let mut new_regular = std::collections::BTreeMap::new();
180                    for old_nid in old_regular {
181                        if let Some(&new_nid) = node_id_map.get(&old_nid) {
182                            if let Some(item) = ht.regular_hit_test_nodes.remove(&old_nid) {
183                                new_regular.insert(new_nid, item);
184                            }
185                        }
186                    }
187                    ht.regular_hit_test_nodes = new_regular;
188
189                    // Remap scroll_hit_test_nodes
190                    let old_scroll: Vec<_> = ht.scroll_hit_test_nodes.keys().cloned().collect();
191                    let mut new_scroll = std::collections::BTreeMap::new();
192                    for old_nid in old_scroll {
193                        if let Some(&new_nid) = node_id_map.get(&old_nid) {
194                            if let Some(item) = ht.scroll_hit_test_nodes.remove(&old_nid) {
195                                new_scroll.insert(new_nid, item);
196                            }
197                        }
198                    }
199                    ht.scroll_hit_test_nodes = new_scroll;
200
201                    // Remap cursor_hit_test_nodes
202                    let old_cursor: Vec<_> = ht.cursor_hit_test_nodes.keys().cloned().collect();
203                    let mut new_cursor = std::collections::BTreeMap::new();
204                    for old_nid in old_cursor {
205                        if let Some(&new_nid) = node_id_map.get(&old_nid) {
206                            if let Some(item) = ht.cursor_hit_test_nodes.remove(&old_nid) {
207                                new_cursor.insert(new_nid, item);
208                            }
209                        }
210                    }
211                    ht.cursor_hit_test_nodes = new_cursor;
212
213                    // Remap scrollbar_hit_test_nodes (ScrollbarHitId contains NodeId)
214                    let old_sb: Vec<_> = ht.scrollbar_hit_test_nodes.keys().cloned().collect();
215                    let mut new_sb = std::collections::BTreeMap::new();
216                    for old_key in old_sb {
217                        let new_key = remap_scrollbar_hit_id(&old_key, dom_id, node_id_map);
218                        if let Some(item) = ht.scrollbar_hit_test_nodes.remove(&old_key) {
219                            new_sb.insert(new_key, item);
220                        }
221                    }
222                    ht.scrollbar_hit_test_nodes = new_sb;
223                }
224            }
225        }
226    }
227}
228
229impl Default for HoverManager {
230    fn default() -> Self {
231        Self::new()
232    }
233}
234
235/// Remap a ScrollbarHitId's NodeId using the reconciliation map.
236/// If the NodeId's DomId doesn't match, or the NodeId isn't in the map, returns unchanged.
237fn remap_scrollbar_hit_id(
238    id: &azul_core::hit_test::ScrollbarHitId,
239    dom_id: azul_core::dom::DomId,
240    node_id_map: &std::collections::BTreeMap<azul_core::id::NodeId, azul_core::id::NodeId>,
241) -> azul_core::hit_test::ScrollbarHitId {
242    use azul_core::hit_test::ScrollbarHitId;
243    match id {
244        ScrollbarHitId::VerticalTrack(d, n) if *d == dom_id => {
245            ScrollbarHitId::VerticalTrack(*d, *node_id_map.get(n).unwrap_or(n))
246        }
247        ScrollbarHitId::VerticalThumb(d, n) if *d == dom_id => {
248            ScrollbarHitId::VerticalThumb(*d, *node_id_map.get(n).unwrap_or(n))
249        }
250        ScrollbarHitId::HorizontalTrack(d, n) if *d == dom_id => {
251            ScrollbarHitId::HorizontalTrack(*d, *node_id_map.get(n).unwrap_or(n))
252        }
253        ScrollbarHitId::HorizontalThumb(d, n) if *d == dom_id => {
254            ScrollbarHitId::HorizontalThumb(*d, *node_id_map.get(n).unwrap_or(n))
255        }
256        other => other.clone(),
257    }
258}