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/// 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`].
20fn 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)]
36pub 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, Eq)]
53pub 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
59impl HoverManager {
60    /// Create a new empty `HoverManager`
61    #[must_use] pub const fn new() -> Self {
62        Self {
63            hover_histories: BTreeMap::new(),
64        }
65    }
66
67    /// (input points, total history entries across all points). Used by
68    /// `AZ_E2E_TEST` to watch for unbounded growth.
69    #[must_use] pub fn debug_counts(&self) -> (usize, usize) {
70        let points = self.hover_histories.len();
71        let total: usize = self.hover_histories.values().map(VecDeque::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    pub fn push_hit_test(&mut self, input_id: InputPointId, hit_test: FullHitTest) {
80        let history = self
81            .hover_histories
82            .entry(input_id)
83            .or_insert_with(|| VecDeque::with_capacity(MAX_HOVER_HISTORY));
84
85        // Add to front (most recent)
86        history.push_front(hit_test);
87
88        // Remove oldest if we exceed the limit
89        if history.len() > MAX_HOVER_HISTORY {
90            history.pop_back();
91        }
92    }
93
94    /// Remove an input point's history (e.g., when touch ends)
95    pub fn remove_input_point(&mut self, input_id: &InputPointId) {
96        self.hover_histories.remove(input_id);
97    }
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    #[must_use] pub fn get_current(&self, input_id: &InputPointId) -> Option<&FullHitTest> {
103        self.hover_histories
104            .get(input_id)
105            .and_then(|history| history.front())
106    }
107
108    /// Get the most recent mouse cursor hit test (convenience method)
109    #[must_use] pub fn get_current_mouse(&self) -> Option<&FullHitTest> {
110        self.get_current(&InputPointId::Mouse)
111    }
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    #[must_use] pub fn get_frame(&self, input_id: &InputPointId, frames_ago: usize) -> Option<&FullHitTest> {
118        self.hover_histories
119            .get(input_id)
120            .and_then(|history| history.get(frames_ago))
121    }
122
123    /// Get the entire hover history for an input point (most recent first)
124    #[must_use] 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    #[must_use] pub fn get_active_input_points(&self) -> Vec<InputPointId> {
130        self.hover_histories.keys().copied().collect()
131    }
132
133    /// Get the number of frames in history for an input point
134    #[must_use] pub fn frame_count(&self, input_id: &InputPointId) -> usize {
135        self.hover_histories
136            .get(input_id)
137            .map_or(0, VecDeque::len)
138    }
139
140    /// Purge every recorded hit-test entry for `dom_id` across all input
141    /// points and all history frames.
142    ///
143    /// Called when a `VirtualView` child DOM is rebuilt IN PLACE (fresh `NodeIds`,
144    /// no reconcile mapping — e.g. a `MapWidget` pan rebuilding the tile grid):
145    /// the recorded hits for that DOM reference the OLD generation's `NodeIds`,
146    /// and consumers that resolve them against the NEW styled DOM read out of
147    /// bounds (the `hit_test.rs` cursor panic: "len is 25 but the index is 27")
148    /// or target the wrong node. Unlike incremental reconciles there is no
149    /// `NodeId` map to `remap` with, so the only safe option is to forget that
150    /// DOM's hits; the next pointer move re-populates them from a fresh
151    /// hit test.
152    pub fn purge_dom(&mut self, dom_id: &azul_core::dom::DomId) {
153        for history in self.hover_histories.values_mut() {
154            for frame in history.iter_mut() {
155                frame.hovered_nodes.remove(dom_id);
156            }
157        }
158    }
159
160    /// Clear all hover history for all input points
161    pub fn clear(&mut self) {
162        self.hover_histories.clear();
163    }
164
165    /// Clear history for a specific input point
166    pub(crate) fn clear_input_point(&mut self, input_id: &InputPointId) {
167        if let Some(history) = self.hover_histories.get_mut(input_id) {
168            history.clear();
169        }
170    }
171
172    /// Check if we have enough frames for gesture detection on an input point
173    ///
174    /// `DragStart` detection requires analyzing movement over multiple frames.
175    /// This returns true if we have at least 2 frames of history.
176    #[must_use] pub fn has_sufficient_history_for_gestures(&self, input_id: &InputPointId) -> bool {
177        self.frame_count(input_id) >= 2
178    }
179
180    /// Check if any input point has enough history for gesture detection
181    #[must_use] pub fn any_has_sufficient_history_for_gestures(&self) -> bool {
182        self.hover_histories
183            .iter()
184            .any(|(_, history)| history.len() >= 2)
185    }
186
187    /// Get the deepest hovered node from the current mouse hit test.
188    ///
189    /// Returns the `NodeId` of the most specific (deepest in DOM tree) node
190    /// that the mouse cursor is currently over, or None if not hovering anything.
191    ///
192    /// NOTE: Assumes single-DOM architecture (uses `DomId { inner: 0 }`).
193    #[must_use] pub fn current_hover_node(&self) -> Option<azul_core::id::NodeId> {
194        let current = self.get_current_mouse()?;
195        let dom_id = azul_core::dom::DomId { inner: 0 };
196        let ht = current.hovered_nodes.get(&dom_id)?;
197        ht.regular_hit_test_nodes.keys().last().copied()
198    }
199
200    /// Get the deepest hovered node from the previous frame's mouse hit test.
201    ///
202    /// Returns the `NodeId` from one frame ago, or None if not hovering anything
203    /// or no previous frame exists.
204    ///
205    /// NOTE: Assumes single-DOM architecture (uses `DomId { inner: 0 }`).
206    #[must_use] pub fn previous_hover_node(&self) -> Option<azul_core::id::NodeId> {
207        let history = self.hover_histories.get(&InputPointId::Mouse)?;
208        let previous = history.get(1)?; // index 1 = one frame ago
209        let dom_id = azul_core::dom::DomId { inner: 0 };
210        let ht = previous.hovered_nodes.get(&dom_id)?;
211        ht.regular_hit_test_nodes.keys().last().copied()
212    }
213
214    /// Multi-DOM aware: the deepest hovered node across ALL hit DOMs (current
215    /// frame). Returns a full `DomNodeId` so events can target `VirtualView` /
216    /// iframe child DOMs, not just the root.
217    ///
218    /// Selection rule: prefer the most-nested DOM that was hit. Child DOMs
219    /// (`VirtualView` / iframe content) always have higher `DomId`s than their
220    /// host and are composited on top of it, so the highest hit `DomId` is the
221    /// front-most surface. Within that DOM the deepest node (last in `NodeId`
222    /// order) is the W3C event target; bubbling then reaches ancestor handlers.
223    ///
224    /// For single-DOM apps only `DomId 0` is ever hit, so this is equivalent to
225    /// [`current_hover_node`] wrapped in `DomId { inner: 0 }`.
226    #[must_use] pub fn current_hover_node_full(&self) -> Option<azul_core::dom::DomNodeId> {
227        deepest_node_across_doms(self.get_current_mouse()?)
228    }
229
230    /// Multi-DOM aware counterpart of [`previous_hover_node`] (one frame ago).
231    #[must_use] pub fn previous_hover_node_full(&self) -> Option<azul_core::dom::DomNodeId> {
232        let history = self.hover_histories.get(&InputPointId::Mouse)?;
233        deepest_node_across_doms(history.get(1)?)
234    }
235
236}
237
238impl crate::managers::NodeIdRemap for HoverManager {
239    /// Remap `NodeIds` in all hover histories after DOM reconciliation.
240    ///
241    /// Hits on unmounted nodes are dropped (they cannot be hovered any more) —
242    /// keeping them would make the hover history describe a node that no longer
243    /// exists at that index.
244    fn remap_node_ids(&mut self, dom_id: azul_core::dom::DomId, map: &crate::managers::NodeIdMap) {
245        let node_id_map = map.as_btree_map();
246        for history in self.hover_histories.values_mut() {
247            for hit_test in history.iter_mut() {
248                if let Some(ht) = hit_test.hovered_nodes.get_mut(&dom_id) {
249                    crate::managers::remap_keys(&mut ht.regular_hit_test_nodes, map);
250                    crate::managers::remap_keys(&mut ht.scroll_hit_test_nodes, map);
251                    crate::managers::remap_keys(&mut ht.cursor_hit_test_nodes, map);
252
253                    // Remap scrollbar_hit_test_nodes (ScrollbarHitId contains NodeId)
254                    let old_sb: Vec<_> = ht.scrollbar_hit_test_nodes.keys().copied().collect();
255                    let mut new_sb = BTreeMap::new();
256                    for old_key in old_sb {
257                        let Some(new_key) = remap_scrollbar_hit_id(&old_key, dom_id, node_id_map)
258                        else {
259                            // node unmounted — drop the scrollbar hit
260                            ht.scrollbar_hit_test_nodes.remove(&old_key);
261                            continue;
262                        };
263                        if let Some(item) = ht.scrollbar_hit_test_nodes.remove(&old_key) {
264                            new_sb.insert(new_key, item);
265                        }
266                    }
267                    ht.scrollbar_hit_test_nodes = new_sb;
268                }
269            }
270        }
271    }
272}
273
274impl Default for HoverManager {
275    fn default() -> Self {
276        Self::new()
277    }
278}
279
280/// Remap a `ScrollbarHitId`'s `NodeId` using the reconciliation map.
281/// `None` = the node was unmounted, so the hit must be dropped.
282/// A `ScrollbarHitId` for a different `DomId` is returned unchanged.
283fn remap_scrollbar_hit_id(
284    id: &azul_core::hit_test::ScrollbarHitId,
285    dom_id: azul_core::dom::DomId,
286    node_id_map: &BTreeMap<azul_core::id::NodeId, azul_core::id::NodeId>,
287) -> Option<azul_core::hit_test::ScrollbarHitId> {
288    use azul_core::hit_test::ScrollbarHitId;
289    Some(match id {
290        ScrollbarHitId::VerticalTrack(d, n) if *d == dom_id => {
291            ScrollbarHitId::VerticalTrack(*d, *node_id_map.get(n)?)
292        }
293        ScrollbarHitId::VerticalThumb(d, n) if *d == dom_id => {
294            ScrollbarHitId::VerticalThumb(*d, *node_id_map.get(n)?)
295        }
296        ScrollbarHitId::HorizontalTrack(d, n) if *d == dom_id => {
297            ScrollbarHitId::HorizontalTrack(*d, *node_id_map.get(n)?)
298        }
299        ScrollbarHitId::HorizontalThumb(d, n) if *d == dom_id => {
300            ScrollbarHitId::HorizontalThumb(*d, *node_id_map.get(n)?)
301        }
302        other => *other,
303    })
304}