Skip to main content

fret_ui_headless/
hover_intent.rs

1//! Small, reusable hover intent state machine (delay open/close).
2//!
3//! This is intended for tooltip / hover-card style overlays where the open state depends on
4//! pointer hover plus a delay, and we want a deterministic, testable contract.
5
6#[derive(Debug, Clone, Copy)]
7pub struct HoverIntentConfig {
8    pub open_delay_ticks: u64,
9    pub close_delay_ticks: u64,
10}
11
12impl HoverIntentConfig {
13    pub fn new(open_delay_ticks: u64, close_delay_ticks: u64) -> Self {
14        Self {
15            open_delay_ticks,
16            close_delay_ticks,
17        }
18    }
19}
20
21#[derive(Debug, Default, Clone, Copy)]
22pub struct HoverIntentState {
23    open: bool,
24    hover_start: Option<u64>,
25    leave_start: Option<u64>,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct HoverIntentUpdate {
30    pub open: bool,
31    pub wants_continuous_ticks: bool,
32}
33
34impl HoverIntentState {
35    pub fn is_open(&self) -> bool {
36        self.open
37    }
38
39    pub fn set_open(&mut self, open: bool) {
40        if self.open == open {
41            return;
42        }
43        self.open = open;
44        self.hover_start = None;
45        self.leave_start = None;
46    }
47
48    pub fn update(&mut self, hovered: bool, now: u64, cfg: HoverIntentConfig) -> HoverIntentUpdate {
49        if hovered {
50            self.leave_start = None;
51            if !self.open {
52                if cfg.open_delay_ticks == 0 {
53                    self.open = true;
54                    self.hover_start = None;
55                } else {
56                    let start = self.hover_start.get_or_insert(now);
57                    let elapsed = now.saturating_sub(*start);
58                    if elapsed >= cfg.open_delay_ticks {
59                        self.open = true;
60                        self.hover_start = None;
61                    }
62                }
63            }
64        } else {
65            self.hover_start = None;
66            if self.open {
67                if cfg.close_delay_ticks == 0 {
68                    self.open = false;
69                    self.leave_start = None;
70                } else {
71                    let start = self.leave_start.get_or_insert(now);
72                    let elapsed = now.saturating_sub(*start);
73                    if elapsed >= cfg.close_delay_ticks {
74                        self.open = false;
75                        self.leave_start = None;
76                    }
77                }
78            } else {
79                self.leave_start = None;
80            }
81        }
82
83        let wants_continuous_ticks = (hovered && !self.open && cfg.open_delay_ticks > 0)
84            || (!hovered && self.open && cfg.close_delay_ticks > 0);
85
86        HoverIntentUpdate {
87            open: self.open,
88            wants_continuous_ticks,
89        }
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn opens_after_delay_and_closes_after_delay() {
99        let cfg = HoverIntentConfig::new(2, 3);
100        let mut st = HoverIntentState::default();
101
102        // Hover for 0,1 ticks: still closed.
103        assert_eq!(
104            st.update(true, 0, cfg),
105            HoverIntentUpdate {
106                open: false,
107                wants_continuous_ticks: true
108            }
109        );
110        assert_eq!(
111            st.update(true, 1, cfg),
112            HoverIntentUpdate {
113                open: false,
114                wants_continuous_ticks: true
115            }
116        );
117
118        // At tick 2 (elapsed >= 2): open.
119        assert_eq!(
120            st.update(true, 2, cfg),
121            HoverIntentUpdate {
122                open: true,
123                wants_continuous_ticks: false
124            }
125        );
126
127        // Leave: close after 3 ticks.
128        assert_eq!(
129            st.update(false, 3, cfg),
130            HoverIntentUpdate {
131                open: true,
132                wants_continuous_ticks: true
133            }
134        );
135        assert_eq!(
136            st.update(false, 5, cfg),
137            HoverIntentUpdate {
138                open: true,
139                wants_continuous_ticks: true
140            }
141        );
142        assert_eq!(
143            st.update(false, 6, cfg),
144            HoverIntentUpdate {
145                open: false,
146                wants_continuous_ticks: false
147            }
148        );
149    }
150
151    #[test]
152    fn zero_delays_toggle_immediately() {
153        let cfg = HoverIntentConfig::new(0, 0);
154        let mut st = HoverIntentState::default();
155
156        assert_eq!(
157            st.update(true, 10, cfg),
158            HoverIntentUpdate {
159                open: true,
160                wants_continuous_ticks: false
161            }
162        );
163        assert_eq!(
164            st.update(false, 11, cfg),
165            HoverIntentUpdate {
166                open: false,
167                wants_continuous_ticks: false
168            }
169        );
170    }
171
172    #[test]
173    fn set_open_resets_pending_delays() {
174        let cfg = HoverIntentConfig::new(5, 5);
175        let mut st = HoverIntentState::default();
176
177        // Begin opening delay.
178        let out0 = st.update(true, 0, cfg);
179        assert!(!out0.open);
180        assert!(out0.wants_continuous_ticks);
181
182        // Force open, then ensure we don't keep "opening" due to stale timers.
183        st.set_open(true);
184        let out1 = st.update(true, 1, cfg);
185        assert!(out1.open);
186        assert!(!out1.wants_continuous_ticks);
187
188        // Begin closing delay, then force closed and ensure we stop delaying.
189        let out2 = st.update(false, 2, cfg);
190        assert!(out2.open);
191        assert!(out2.wants_continuous_ticks);
192
193        st.set_open(false);
194        let out3 = st.update(false, 3, cfg);
195        assert!(!out3.open);
196        assert!(!out3.wants_continuous_ticks);
197    }
198}