Skip to main content

fret_ui_headless/
scroll_area_visibility.rs

1use crate::scroll_area::ScrollAreaType;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4enum ScrollVisibilityState {
5    Hidden,
6    Scrolling,
7    Interacting,
8    Idle,
9}
10
11#[derive(Debug, Clone, Copy)]
12pub struct ScrollAreaVisibilityConfig {
13    /// Mirrors Radix `scrollHideDelay` (default 600ms).
14    ///
15    /// Fret expresses this in monotonic "ticks" supplied by the driver.
16    pub scroll_hide_delay_ticks: u64,
17    /// Mirrors Radix's internal "scroll end" debounce (100ms).
18    pub scroll_end_debounce_ticks: u64,
19}
20
21#[derive(Debug, Clone, Copy)]
22pub struct ScrollAreaVisibilityInput {
23    pub ty: ScrollAreaType,
24    pub hovered: bool,
25    pub has_overflow: bool,
26    pub scrolled: bool,
27    pub tick: u64,
28}
29
30#[derive(Debug, Clone, Copy)]
31pub struct ScrollAreaVisibilityOutput {
32    pub visible: bool,
33    /// When true, the driver should keep scheduling frames so time-based transitions can fire.
34    pub animating: bool,
35}
36
37#[derive(Debug, Clone)]
38pub struct ScrollAreaVisibility {
39    last_ty: Option<ScrollAreaType>,
40    hover_visible_until: Option<u64>,
41    was_hovered: bool,
42    scroll_state: ScrollVisibilityState,
43    last_scroll_tick: Option<u64>,
44    scroll_hide_deadline: Option<u64>,
45}
46
47impl Default for ScrollAreaVisibility {
48    fn default() -> Self {
49        Self {
50            last_ty: None,
51            hover_visible_until: None,
52            was_hovered: false,
53            scroll_state: ScrollVisibilityState::Hidden,
54            last_scroll_tick: None,
55            scroll_hide_deadline: None,
56        }
57    }
58}
59
60impl ScrollAreaVisibility {
61    pub fn update(
62        &mut self,
63        input: ScrollAreaVisibilityInput,
64        config: ScrollAreaVisibilityConfig,
65    ) -> ScrollAreaVisibilityOutput {
66        if self.last_ty != Some(input.ty) {
67            self.reset_for_type(input.ty);
68        }
69
70        if !input.has_overflow {
71            self.reset_for_type(input.ty);
72            return ScrollAreaVisibilityOutput {
73                visible: false,
74                animating: false,
75            };
76        }
77
78        match input.ty {
79            ScrollAreaType::Always | ScrollAreaType::Auto => ScrollAreaVisibilityOutput {
80                visible: true,
81                animating: false,
82            },
83            ScrollAreaType::Hover => self.update_hover(input, config),
84            ScrollAreaType::Scroll => self.update_scroll(input, config),
85        }
86    }
87
88    fn reset_for_type(&mut self, ty: ScrollAreaType) {
89        self.last_ty = Some(ty);
90        self.hover_visible_until = None;
91        self.was_hovered = false;
92        self.scroll_state = ScrollVisibilityState::Hidden;
93        self.last_scroll_tick = None;
94        self.scroll_hide_deadline = None;
95    }
96
97    fn update_hover(
98        &mut self,
99        input: ScrollAreaVisibilityInput,
100        config: ScrollAreaVisibilityConfig,
101    ) -> ScrollAreaVisibilityOutput {
102        if input.hovered {
103            self.was_hovered = true;
104            self.hover_visible_until = None;
105            return ScrollAreaVisibilityOutput {
106                visible: true,
107                animating: false,
108            };
109        }
110
111        if self.was_hovered {
112            self.was_hovered = false;
113            self.hover_visible_until =
114                Some(input.tick.saturating_add(config.scroll_hide_delay_ticks));
115        }
116
117        let Some(deadline) = self.hover_visible_until else {
118            return ScrollAreaVisibilityOutput {
119                visible: false,
120                animating: false,
121            };
122        };
123
124        let visible = input.tick < deadline;
125        if !visible {
126            self.hover_visible_until = None;
127        }
128
129        ScrollAreaVisibilityOutput {
130            visible,
131            animating: visible,
132        }
133    }
134
135    fn update_scroll(
136        &mut self,
137        input: ScrollAreaVisibilityInput,
138        config: ScrollAreaVisibilityConfig,
139    ) -> ScrollAreaVisibilityOutput {
140        if input.scrolled {
141            self.last_scroll_tick = Some(input.tick);
142            self.scroll_hide_deadline = None;
143            match self.scroll_state {
144                ScrollVisibilityState::Hidden | ScrollVisibilityState::Idle => {
145                    self.scroll_state = ScrollVisibilityState::Scrolling;
146                }
147                ScrollVisibilityState::Scrolling | ScrollVisibilityState::Interacting => {}
148            }
149        }
150
151        if input.hovered {
152            match self.scroll_state {
153                ScrollVisibilityState::Scrolling | ScrollVisibilityState::Idle => {
154                    self.scroll_state = ScrollVisibilityState::Interacting;
155                    self.scroll_hide_deadline = None;
156                }
157                ScrollVisibilityState::Hidden | ScrollVisibilityState::Interacting => {}
158            }
159        } else if self.scroll_state == ScrollVisibilityState::Interacting {
160            self.scroll_state = ScrollVisibilityState::Idle;
161            self.scroll_hide_deadline =
162                Some(input.tick.saturating_add(config.scroll_hide_delay_ticks));
163        }
164
165        if self.scroll_state == ScrollVisibilityState::Scrolling
166            && let Some(last) = self.last_scroll_tick
167            && input.tick.saturating_sub(last) >= config.scroll_end_debounce_ticks
168        {
169            self.scroll_state = ScrollVisibilityState::Idle;
170            self.scroll_hide_deadline =
171                Some(input.tick.saturating_add(config.scroll_hide_delay_ticks));
172        }
173
174        if self.scroll_state == ScrollVisibilityState::Idle
175            && let Some(deadline) = self.scroll_hide_deadline
176            && input.tick >= deadline
177        {
178            self.scroll_state = ScrollVisibilityState::Hidden;
179            self.scroll_hide_deadline = None;
180        }
181
182        let visible = self.scroll_state != ScrollVisibilityState::Hidden;
183        let animating = match self.scroll_state {
184            ScrollVisibilityState::Hidden | ScrollVisibilityState::Interacting => false,
185            ScrollVisibilityState::Scrolling | ScrollVisibilityState::Idle => true,
186        };
187
188        ScrollAreaVisibilityOutput { visible, animating }
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    const HIDE: u64 = 10;
197    const DEBOUNCE: u64 = 2;
198
199    fn cfg() -> ScrollAreaVisibilityConfig {
200        ScrollAreaVisibilityConfig {
201            scroll_hide_delay_ticks: HIDE,
202            scroll_end_debounce_ticks: DEBOUNCE,
203        }
204    }
205
206    #[test]
207    fn hover_hides_after_delay() {
208        let mut vis = ScrollAreaVisibility::default();
209
210        let out_init = vis.update(
211            ScrollAreaVisibilityInput {
212                ty: ScrollAreaType::Hover,
213                hovered: false,
214                has_overflow: true,
215                scrolled: false,
216                tick: 0,
217            },
218            cfg(),
219        );
220        assert!(!out_init.visible);
221        assert!(!out_init.animating);
222
223        let out0 = vis.update(
224            ScrollAreaVisibilityInput {
225                ty: ScrollAreaType::Hover,
226                hovered: true,
227                has_overflow: true,
228                scrolled: false,
229                tick: 1,
230            },
231            cfg(),
232        );
233        assert!(out0.visible);
234        assert!(!out0.animating);
235
236        let out1 = vis.update(
237            ScrollAreaVisibilityInput {
238                ty: ScrollAreaType::Hover,
239                hovered: false,
240                has_overflow: true,
241                scrolled: false,
242                tick: 2,
243            },
244            cfg(),
245        );
246        assert!(out1.visible);
247        assert!(out1.animating);
248
249        let out2 = vis.update(
250            ScrollAreaVisibilityInput {
251                ty: ScrollAreaType::Hover,
252                hovered: false,
253                has_overflow: true,
254                scrolled: false,
255                tick: 2 + HIDE,
256            },
257            cfg(),
258        );
259        assert!(!out2.visible);
260        assert!(!out2.animating);
261
262        let out3 = vis.update(
263            ScrollAreaVisibilityInput {
264                ty: ScrollAreaType::Hover,
265                hovered: false,
266                has_overflow: true,
267                scrolled: false,
268                tick: 3 + HIDE,
269            },
270            cfg(),
271        );
272        assert!(
273            !out3.visible,
274            "hover mode should remain hidden after the delay"
275        );
276        assert!(!out3.animating);
277    }
278
279    #[test]
280    fn scroll_shows_while_scrolling_then_hides() {
281        let mut vis = ScrollAreaVisibility::default();
282
283        let out0 = vis.update(
284            ScrollAreaVisibilityInput {
285                ty: ScrollAreaType::Scroll,
286                hovered: false,
287                has_overflow: true,
288                scrolled: false,
289                tick: 1,
290            },
291            cfg(),
292        );
293        assert!(!out0.visible);
294
295        let out1 = vis.update(
296            ScrollAreaVisibilityInput {
297                ty: ScrollAreaType::Scroll,
298                hovered: false,
299                has_overflow: true,
300                scrolled: true,
301                tick: 2,
302            },
303            cfg(),
304        );
305        assert!(out1.visible);
306        assert!(out1.animating);
307
308        let out2 = vis.update(
309            ScrollAreaVisibilityInput {
310                ty: ScrollAreaType::Scroll,
311                hovered: false,
312                has_overflow: true,
313                scrolled: false,
314                tick: 2 + DEBOUNCE,
315            },
316            cfg(),
317        );
318        assert!(out2.visible);
319        assert!(out2.animating);
320
321        let out3 = vis.update(
322            ScrollAreaVisibilityInput {
323                ty: ScrollAreaType::Scroll,
324                hovered: false,
325                has_overflow: true,
326                scrolled: false,
327                tick: 2 + DEBOUNCE + HIDE,
328            },
329            cfg(),
330        );
331        assert!(!out3.visible);
332        assert!(!out3.animating);
333    }
334
335    #[test]
336    fn scroll_interaction_keeps_visible_until_leave() {
337        let mut vis = ScrollAreaVisibility::default();
338
339        let _ = vis.update(
340            ScrollAreaVisibilityInput {
341                ty: ScrollAreaType::Scroll,
342                hovered: false,
343                has_overflow: true,
344                scrolled: true,
345                tick: 1,
346            },
347            cfg(),
348        );
349
350        let out0 = vis.update(
351            ScrollAreaVisibilityInput {
352                ty: ScrollAreaType::Scroll,
353                hovered: true,
354                has_overflow: true,
355                scrolled: false,
356                tick: 2,
357            },
358            cfg(),
359        );
360        assert!(out0.visible);
361        assert!(!out0.animating);
362
363        let out1 = vis.update(
364            ScrollAreaVisibilityInput {
365                ty: ScrollAreaType::Scroll,
366                hovered: false,
367                has_overflow: true,
368                scrolled: false,
369                tick: 3,
370            },
371            cfg(),
372        );
373        assert!(out1.visible);
374        assert!(out1.animating);
375    }
376}