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

            
8
use std::collections::{BTreeMap, VecDeque};
9

            
10
use crate::hit_test::FullHitTest;
11

            
12
/// Maximum number of frames to keep in hover history
13
const MAX_HOVER_HISTORY: usize = 5;
14

            
15
/// Pick the front-most deepest hovered node across all hit DOMs.
16
///
17
/// Iterates DOMs from highest `DomId` (most-nested child, composited on top)
18
/// to lowest and returns the deepest node (last in NodeId order) of the first
19
/// DOM that actually has a regular hit. See [`HoverManager::current_hover_node_full`].
20
fn deepest_node_across_doms(ht: &FullHitTest) -> Option<azul_core::dom::DomNodeId> {
21
    for (dom_id, hit) in ht.hovered_nodes.iter().rev() {
22
        if let Some(node_id) = hit.regular_hit_test_nodes.keys().last().copied() {
23
            return Some(azul_core::dom::DomNodeId {
24
                dom: *dom_id,
25
                node: azul_core::styled_dom::NodeHierarchyItemId::from_crate_internal(Some(
26
                    node_id,
27
                )),
28
            });
29
        }
30
    }
31
    None
32
}
33

            
34
/// Identifier for an input point (mouse, touch, pen, etc.)
35
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
36
pub enum InputPointId {
37
    /// Mouse cursor
38
    Mouse,
39
    /// Touch point with unique ID (from TouchEvent.id)
40
    Touch(u64),
41
}
42

            
43
/// Manages hover state history for all input points
44
///
45
/// Records hit test results for mouse and touch inputs over multiple frames:
46
/// - DragStart detection (requires movement threshold over multiple frames)
47
/// - Hover-over event detection
48
/// - Multi-touch gesture detection
49
/// - Input path analysis
50
///
51
/// The manager maintains a separate history for each active input point.
52
#[derive(Debug, Clone, PartialEq)]
53
pub struct HoverManager {
54
    /// Hit test history for each input point
55
    /// Each point has its own ring buffer of the last N frames
56
    hover_histories: BTreeMap<InputPointId, VecDeque<FullHitTest>>,
57
}
58

            
59
impl HoverManager {
60
    /// Create a new empty HoverManager
61
3718
    pub fn new() -> Self {
62
3718
        Self {
63
3718
            hover_histories: BTreeMap::new(),
64
3718
        }
65
3718
    }
66

            
67
    /// (input points, total history entries across all points). Used by
68
    /// `AZ_E2E_TEST` to watch for unbounded growth.
69
    pub fn debug_counts(&self) -> (usize, usize) {
70
        let points = self.hover_histories.len();
71
        let total: usize = self.hover_histories.values().map(|h| h.len()).sum();
72
        (points, total)
73
    }
74

            
75
    /// Push a new hit test result for a specific input point
76
    ///
77
    /// The most recent result is always at index 0 for that input point.
78
    /// If the history is full, the oldest frame is dropped.
79
616
    pub fn push_hit_test(&mut self, input_id: InputPointId, hit_test: FullHitTest) {
80
616
        let history = self
81
616
            .hover_histories
82
616
            .entry(input_id)
83
616
            .or_insert_with(|| VecDeque::with_capacity(MAX_HOVER_HISTORY));
84

            
85
        // Add to front (most recent)
86
616
        history.push_front(hit_test);
87

            
88
        // Remove oldest if we exceed the limit
89
616
        if history.len() > MAX_HOVER_HISTORY {
90
88
            history.pop_back();
91
528
        }
92
616
    }
93

            
94
    /// Remove an input point's history (e.g., when touch ends)
95
44
    pub fn remove_input_point(&mut self, input_id: &InputPointId) {
96
44
        self.hover_histories.remove(input_id);
97
44
    }
98

            
99
    /// Get the most recent hit test result for an input point
100
    ///
101
    /// Returns None if no hit tests have been recorded for this input point.
102
276
    pub fn get_current(&self, input_id: &InputPointId) -> Option<&FullHitTest> {
103
276
        self.hover_histories
104
276
            .get(input_id)
105
276
            .and_then(|history| history.front())
106
276
    }
107

            
108
    /// Get the most recent mouse cursor hit test (convenience method)
109
56
    pub fn get_current_mouse(&self) -> Option<&FullHitTest> {
110
56
        self.get_current(&InputPointId::Mouse)
111
56
    }
112

            
113
    /// Get the hit test result from N frames ago for an input point
114
    /// (0 = current frame)
115
    ///
116
    /// Returns None if the requested frame is not in history.
117
22
    pub fn get_frame(&self, input_id: &InputPointId, frames_ago: usize) -> Option<&FullHitTest> {
118
22
        self.hover_histories
119
22
            .get(input_id)
120
22
            .and_then(|history| history.get(frames_ago))
121
22
    }
122

            
123
    /// Get the entire hover history for an input point (most recent first)
124
    pub fn get_history(&self, input_id: &InputPointId) -> Option<&VecDeque<FullHitTest>> {
125
        self.hover_histories.get(input_id)
126
    }
127

            
128
    /// Get all currently tracked input points
129
44
    pub fn get_active_input_points(&self) -> Vec<InputPointId> {
130
44
        self.hover_histories.keys().copied().collect()
131
44
    }
132

            
133
    /// Get the number of frames in history for an input point
134
352
    pub fn frame_count(&self, input_id: &InputPointId) -> usize {
135
352
        self.hover_histories
136
352
            .get(input_id)
137
352
            .map(|h| h.len())
138
352
            .unwrap_or(0)
139
352
    }
140

            
141
    /// Purge every recorded hit-test entry for `dom_id` across all input
142
    /// points and all history frames.
143
    ///
144
    /// Called when a VirtualView child DOM is rebuilt IN PLACE (fresh NodeIds,
145
    /// no reconcile mapping — e.g. a MapWidget pan rebuilding the tile grid):
146
    /// the recorded hits for that DOM reference the OLD generation's NodeIds,
147
    /// and consumers that resolve them against the NEW styled DOM read out of
148
    /// bounds (the hit_test.rs cursor panic: "len is 25 but the index is 27")
149
    /// or target the wrong node. Unlike incremental reconciles there is no
150
    /// NodeId map to `remap` with, so the only safe option is to forget that
151
    /// DOM's hits; the next pointer move re-populates them from a fresh
152
    /// hit test.
153
    pub fn purge_dom(&mut self, dom_id: &azul_core::dom::DomId) {
154
        for history in self.hover_histories.values_mut() {
155
            for frame in history.iter_mut() {
156
                frame.hovered_nodes.remove(dom_id);
157
            }
158
        }
159
    }
160

            
161
    /// Clear all hover history for all input points
162
    pub fn clear(&mut self) {
163
        self.hover_histories.clear();
164
    }
165

            
166
    /// Clear history for a specific input point
167
    pub fn clear_input_point(&mut self, input_id: &InputPointId) {
168
        if let Some(history) = self.hover_histories.get_mut(input_id) {
169
            history.clear();
170
        }
171
    }
172

            
173
    /// Check if we have enough frames for gesture detection on an input point
174
    ///
175
    /// DragStart detection requires analyzing movement over multiple frames.
176
    /// This returns true if we have at least 2 frames of history.
177
132
    pub fn has_sufficient_history_for_gestures(&self, input_id: &InputPointId) -> bool {
178
132
        self.frame_count(input_id) >= 2
179
132
    }
180

            
181
    /// Check if any input point has enough history for gesture detection
182
88
    pub fn any_has_sufficient_history_for_gestures(&self) -> bool {
183
88
        self.hover_histories
184
88
            .iter()
185
88
            .any(|(_, history)| history.len() >= 2)
186
88
    }
187

            
188
    /// Get the deepest hovered node from the current mouse hit test.
189
    ///
190
    /// Returns the NodeId of the most specific (deepest in DOM tree) node
191
    /// that the mouse cursor is currently over, or None if not hovering anything.
192
    ///
193
    /// NOTE: Assumes single-DOM architecture (uses `DomId { inner: 0 }`).
194
    pub fn current_hover_node(&self) -> Option<azul_core::id::NodeId> {
195
        let current = self.get_current_mouse()?;
196
        let dom_id = azul_core::dom::DomId { inner: 0 };
197
        let ht = current.hovered_nodes.get(&dom_id)?;
198
        ht.regular_hit_test_nodes.keys().last().copied()
199
    }
200

            
201
    /// Get the deepest hovered node from the previous frame's mouse hit test.
202
    ///
203
    /// Returns the NodeId from one frame ago, or None if not hovering anything
204
    /// or no previous frame exists.
205
    ///
206
    /// NOTE: Assumes single-DOM architecture (uses `DomId { inner: 0 }`).
207
    pub fn previous_hover_node(&self) -> Option<azul_core::id::NodeId> {
208
        let history = self.hover_histories.get(&InputPointId::Mouse)?;
209
        let previous = history.get(1)?; // index 1 = one frame ago
210
        let dom_id = azul_core::dom::DomId { inner: 0 };
211
        let ht = previous.hovered_nodes.get(&dom_id)?;
212
        ht.regular_hit_test_nodes.keys().last().copied()
213
    }
214

            
215
    /// Multi-DOM aware: the deepest hovered node across ALL hit DOMs (current
216
    /// frame). Returns a full `DomNodeId` so events can target VirtualView /
217
    /// iframe child DOMs, not just the root.
218
    ///
219
    /// Selection rule: prefer the most-nested DOM that was hit. Child DOMs
220
    /// (VirtualView / iframe content) always have higher `DomId`s than their
221
    /// host and are composited on top of it, so the highest hit `DomId` is the
222
    /// front-most surface. Within that DOM the deepest node (last in NodeId
223
    /// order) is the W3C event target; bubbling then reaches ancestor handlers.
224
    ///
225
    /// For single-DOM apps only `DomId 0` is ever hit, so this is equivalent to
226
    /// [`current_hover_node`] wrapped in `DomId { inner: 0 }`.
227
12
    pub fn current_hover_node_full(&self) -> Option<azul_core::dom::DomNodeId> {
228
12
        deepest_node_across_doms(self.get_current_mouse()?)
229
12
    }
230

            
231
    /// Multi-DOM aware counterpart of [`previous_hover_node`] (one frame ago).
232
1
    pub fn previous_hover_node_full(&self) -> Option<azul_core::dom::DomNodeId> {
233
1
        let history = self.hover_histories.get(&InputPointId::Mouse)?;
234
        deepest_node_across_doms(history.get(1)?)
235
1
    }
236

            
237
    /// Remap NodeIds in all hover histories after DOM reconciliation.
238
    ///
239
    /// When the DOM is regenerated, NodeIds can change. This method updates
240
    /// all stored NodeIds in hover histories using the old→new mapping from
241
    /// reconciliation. Nodes not found in the map are removed from hit tests.
242
    pub fn remap_node_ids(
243
        &mut self,
244
        dom_id: azul_core::dom::DomId,
245
        node_id_map: &std::collections::BTreeMap<azul_core::id::NodeId, azul_core::id::NodeId>,
246
    ) {
247
        for history in self.hover_histories.values_mut() {
248
            for hit_test in history.iter_mut() {
249
                if let Some(ht) = hit_test.hovered_nodes.get_mut(&dom_id) {
250
                    remap_btreemap(&mut ht.regular_hit_test_nodes, node_id_map);
251
                    remap_btreemap(&mut ht.scroll_hit_test_nodes, node_id_map);
252
                    remap_btreemap(&mut ht.cursor_hit_test_nodes, node_id_map);
253

            
254
                    // Remap scrollbar_hit_test_nodes (ScrollbarHitId contains NodeId)
255
                    let old_sb: Vec<_> = ht.scrollbar_hit_test_nodes.keys().cloned().collect();
256
                    let mut new_sb = std::collections::BTreeMap::new();
257
                    for old_key in old_sb {
258
                        let new_key = remap_scrollbar_hit_id(&old_key, dom_id, node_id_map);
259
                        if let Some(item) = ht.scrollbar_hit_test_nodes.remove(&old_key) {
260
                            new_sb.insert(new_key, item);
261
                        }
262
                    }
263
                    ht.scrollbar_hit_test_nodes = new_sb;
264
                }
265
            }
266
        }
267
    }
268
}
269

            
270
impl Default for HoverManager {
271
    fn default() -> Self {
272
        Self::new()
273
    }
274
}
275

            
276
/// Remap all keys in a BTreeMap<NodeId, V> using the reconciliation map.
277
/// Entries whose old NodeId is not in the map are dropped.
278
fn remap_btreemap<V>(
279
    map: &mut std::collections::BTreeMap<azul_core::id::NodeId, V>,
280
    node_id_map: &std::collections::BTreeMap<azul_core::id::NodeId, azul_core::id::NodeId>,
281
) {
282
    let old_keys: Vec<_> = map.keys().cloned().collect();
283
    let mut new_map = std::collections::BTreeMap::new();
284
    for old_nid in old_keys {
285
        if let Some(&new_nid) = node_id_map.get(&old_nid) {
286
            if let Some(item) = map.remove(&old_nid) {
287
                new_map.insert(new_nid, item);
288
            }
289
        }
290
    }
291
    *map = new_map;
292
}
293

            
294
/// Remap a ScrollbarHitId's NodeId using the reconciliation map.
295
/// If the NodeId's DomId doesn't match, or the NodeId isn't in the map, returns unchanged.
296
fn remap_scrollbar_hit_id(
297
    id: &azul_core::hit_test::ScrollbarHitId,
298
    dom_id: azul_core::dom::DomId,
299
    node_id_map: &std::collections::BTreeMap<azul_core::id::NodeId, azul_core::id::NodeId>,
300
) -> azul_core::hit_test::ScrollbarHitId {
301
    use azul_core::hit_test::ScrollbarHitId;
302
    match id {
303
        ScrollbarHitId::VerticalTrack(d, n) if *d == dom_id => {
304
            ScrollbarHitId::VerticalTrack(*d, *node_id_map.get(n).unwrap_or(n))
305
        }
306
        ScrollbarHitId::VerticalThumb(d, n) if *d == dom_id => {
307
            ScrollbarHitId::VerticalThumb(*d, *node_id_map.get(n).unwrap_or(n))
308
        }
309
        ScrollbarHitId::HorizontalTrack(d, n) if *d == dom_id => {
310
            ScrollbarHitId::HorizontalTrack(*d, *node_id_map.get(n).unwrap_or(n))
311
        }
312
        ScrollbarHitId::HorizontalThumb(d, n) if *d == dom_id => {
313
            ScrollbarHitId::HorizontalThumb(*d, *node_id_map.get(n).unwrap_or(n))
314
        }
315
        other => other.clone(),
316
    }
317
}