Skip to main content

fret_ui_kit/
tooltip_provider.rs

1use std::collections::HashMap;
2
3use fret_core::{Rect, WindowFrameClockService, WindowMetricsService, time::Duration};
4use fret_runtime::FrameId;
5use fret_runtime::Model;
6use fret_ui::elements::GlobalElementId;
7use fret_ui::{ElementContext, UiHost};
8
9use crate::headless::tooltip_delay_group::{TooltipDelayGroupConfig, TooltipDelayGroupState};
10
11const REFERENCE_FRAME_DELTA_60HZ: Duration = Duration::from_nanos(1_000_000_000 / 60);
12const MAX_DURATION_TICKS: u64 = 10_000;
13
14fn effective_frame_delta_for_cx<H: UiHost>(cx: &ElementContext<'_, H>) -> Duration {
15    let Some(svc) = cx.app.global::<WindowFrameClockService>() else {
16        return REFERENCE_FRAME_DELTA_60HZ;
17    };
18
19    if let Some(fixed) = svc.effective_fixed_delta(cx.window)
20        && fixed > Duration::ZERO
21    {
22        return fixed;
23    }
24
25    let has_window_metrics = cx.app.global::<WindowMetricsService>().is_some();
26    if !has_window_metrics {
27        // Headless tests often drive "frames" without present-time. In that regime, snapshot
28        // deltas can reflect CPU time (near-zero), which would make Duration-to-ticks conversions
29        // explode and turn interaction gates flaky. Use a stable reference delta unless a fixed
30        // delta is explicitly configured.
31        return REFERENCE_FRAME_DELTA_60HZ;
32    }
33
34    svc.snapshot(cx.window)
35        .map(|s| s.delta)
36        .filter(|dt| *dt > Duration::ZERO)
37        .unwrap_or(REFERENCE_FRAME_DELTA_60HZ)
38}
39
40fn duration_to_ticks_ceil(duration: Duration, frame_delta: Duration) -> u64 {
41    if duration == Duration::ZERO {
42        return 0;
43    }
44
45    let frame_delta = if frame_delta == Duration::ZERO {
46        REFERENCE_FRAME_DELTA_60HZ
47    } else {
48        frame_delta
49    };
50
51    let duration_ns = duration.as_nanos();
52    let frame_delta_ns = frame_delta.as_nanos().max(1);
53    let ticks = duration_ns.div_ceil(frame_delta_ns);
54    ticks.clamp(1, MAX_DURATION_TICKS as u128) as u64
55}
56
57/// Converts a wall-clock `Duration` into a best-effort number of frame ticks for the current
58/// element context.
59///
60/// This is intended for overlay interaction policies that express delays in milliseconds upstream
61/// (Radix / Base UI), while Fret's internal state machines remain tick-based.
62pub fn ticks_for_duration_for_cx<H: UiHost>(cx: &ElementContext<'_, H>, duration: Duration) -> u64 {
63    duration_to_ticks_ceil(duration, effective_frame_delta_for_cx(cx))
64}
65
66#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
67pub struct TooltipProviderConfig {
68    pub delay_duration_ticks: u64,
69    pub close_delay_duration_ticks: Option<u64>,
70    pub skip_delay_duration_ticks: u64,
71    pub disable_hoverable_content: bool,
72}
73
74impl TooltipProviderConfig {
75    pub fn new(delay_duration_ticks: u64, skip_delay_duration_ticks: u64) -> Self {
76        Self {
77            delay_duration_ticks,
78            close_delay_duration_ticks: None,
79            skip_delay_duration_ticks,
80            disable_hoverable_content: false,
81        }
82    }
83
84    pub fn close_delay_duration_ticks(mut self, ticks: u64) -> Self {
85        self.close_delay_duration_ticks = Some(ticks);
86        self
87    }
88
89    pub fn disable_hoverable_content(mut self, disable: bool) -> Self {
90        self.disable_hoverable_content = disable;
91        self
92    }
93}
94
95#[derive(Debug, Default, Clone)]
96struct ProviderState {
97    config: TooltipProviderConfig,
98    delay_group: TooltipDelayGroupState,
99    last_opened_token: u64,
100    last_opened_tooltip: Option<GlobalElementId>,
101    pointer_in_transit: bool,
102    pointer_in_transit_model: Option<Model<bool>>,
103    pointer_transit_geometry_model: Option<Model<Option<(Rect, Rect)>>>,
104}
105
106#[derive(Default)]
107struct TooltipProviderService {
108    frame_id: Option<FrameId>,
109    active_stack: Vec<GlobalElementId>,
110    providers: HashMap<GlobalElementId, ProviderState>,
111    root: ProviderState,
112}
113
114impl TooltipProviderService {
115    fn begin_frame(&mut self, frame_id: FrameId) {
116        if self.frame_id == Some(frame_id) {
117            return;
118        }
119        self.frame_id = Some(frame_id);
120        self.active_stack.clear();
121    }
122
123    fn current_provider_id(&self) -> Option<GlobalElementId> {
124        self.active_stack.last().copied()
125    }
126
127    fn current_state_mut(&mut self) -> &mut ProviderState {
128        let Some(id) = self.current_provider_id() else {
129            return &mut self.root;
130        };
131        self.providers.entry(id).or_default()
132    }
133
134    fn current_state(&self) -> &ProviderState {
135        let Some(id) = self.current_provider_id() else {
136            return &self.root;
137        };
138        self.providers.get(&id).unwrap_or(&self.root)
139    }
140}
141
142pub fn with_tooltip_provider<H: UiHost, R>(
143    cx: &mut ElementContext<'_, H>,
144    config: TooltipProviderConfig,
145    f: impl FnOnce(&mut ElementContext<'_, H>) -> R,
146) -> R {
147    cx.scope(|cx| {
148        let provider_id = cx.root_id();
149
150        cx.app
151            .with_global_mut_untracked(TooltipProviderService::default, |svc, app| {
152                svc.begin_frame(app.frame_id());
153                let entry = svc.providers.entry(provider_id).or_default();
154                entry.config = config;
155                svc.active_stack.push(provider_id);
156            });
157
158        let out = f(cx);
159
160        cx.app
161            .with_global_mut_untracked(TooltipProviderService::default, |svc, app| {
162                svc.begin_frame(app.frame_id());
163                let _ = svc.active_stack.pop();
164            });
165
166        out
167    })
168}
169
170pub fn current_config<H: UiHost>(cx: &mut ElementContext<'_, H>) -> TooltipProviderConfig {
171    cx.app
172        .with_global_mut_untracked(TooltipProviderService::default, |svc, app| {
173            svc.begin_frame(app.frame_id());
174            svc.current_state().config
175        })
176}
177
178pub fn open_delay_ticks<H: UiHost>(cx: &mut ElementContext<'_, H>, now: u64) -> u64 {
179    cx.app
180        .with_global_mut_untracked(TooltipProviderService::default, |svc, app| {
181            svc.begin_frame(app.frame_id());
182            let st = svc.current_state();
183            st.delay_group.open_delay_ticks(
184                now,
185                TooltipDelayGroupConfig::new(
186                    st.config.delay_duration_ticks,
187                    st.config.skip_delay_duration_ticks,
188                ),
189            )
190        })
191}
192
193pub fn open_delay_ticks_with_base<H: UiHost>(
194    cx: &mut ElementContext<'_, H>,
195    now: u64,
196    base_delay_ticks: u64,
197) -> u64 {
198    cx.app
199        .with_global_mut_untracked(TooltipProviderService::default, |svc, app| {
200            svc.begin_frame(app.frame_id());
201            let st = svc.current_state();
202            st.delay_group.open_delay_ticks(
203                now,
204                TooltipDelayGroupConfig::new(base_delay_ticks, st.config.skip_delay_duration_ticks),
205            )
206        })
207}
208
209pub fn note_closed<H: UiHost>(cx: &mut ElementContext<'_, H>, now: u64) {
210    cx.app
211        .with_global_mut_untracked(TooltipProviderService::default, |svc, app| {
212            svc.begin_frame(app.frame_id());
213            svc.current_state_mut().delay_group.note_closed(now);
214        });
215}
216
217pub fn last_opened_tooltip<H: UiHost>(
218    cx: &mut ElementContext<'_, H>,
219) -> Option<(GlobalElementId, u64)> {
220    cx.app
221        .with_global_mut_untracked(TooltipProviderService::default, |svc, app| {
222            svc.begin_frame(app.frame_id());
223            let st = svc.current_state();
224            st.last_opened_tooltip.map(|id| (id, st.last_opened_token))
225        })
226}
227
228pub fn note_opened_tooltip<H: UiHost>(
229    cx: &mut ElementContext<'_, H>,
230    tooltip: GlobalElementId,
231) -> u64 {
232    cx.app
233        .with_global_mut_untracked(TooltipProviderService::default, |svc, app| {
234            svc.begin_frame(app.frame_id());
235            let st = svc.current_state_mut();
236            st.last_opened_token = st.last_opened_token.saturating_add(1);
237            st.last_opened_tooltip = Some(tooltip);
238
239            if st.pointer_in_transit {
240                st.pointer_in_transit = false;
241                if let Some(model) = st.pointer_in_transit_model.clone() {
242                    let _ = app.models_mut().update(&model, |v| *v = false);
243                }
244            }
245            if let Some(model) = st.pointer_transit_geometry_model.clone() {
246                let _ = app.models_mut().update(&model, |v| *v = None);
247            }
248            st.last_opened_token
249        })
250}
251
252pub fn pointer_in_transit_model<H: UiHost>(cx: &mut ElementContext<'_, H>) -> Model<bool> {
253    cx.app
254        .with_global_mut_untracked(TooltipProviderService::default, |svc, app| {
255            svc.begin_frame(app.frame_id());
256            let st = svc.current_state_mut();
257            let existing = st.pointer_in_transit_model.clone();
258            if let Some(model) = existing {
259                return model;
260            }
261
262            let model = app.models_mut().insert(st.pointer_in_transit);
263            st.pointer_in_transit_model = Some(model.clone());
264            model
265        })
266}
267
268pub fn pointer_transit_geometry_model<H: UiHost>(
269    cx: &mut ElementContext<'_, H>,
270) -> Model<Option<(Rect, Rect)>> {
271    cx.app
272        .with_global_mut_untracked(TooltipProviderService::default, |svc, app| {
273            svc.begin_frame(app.frame_id());
274            let st = svc.current_state_mut();
275            let existing = st.pointer_transit_geometry_model.clone();
276            if let Some(model) = existing {
277                return model;
278            }
279
280            let model = app.models_mut().insert(None);
281            st.pointer_transit_geometry_model = Some(model.clone());
282            model
283        })
284}
285
286pub fn set_pointer_transit_geometry<H: UiHost>(
287    cx: &mut ElementContext<'_, H>,
288    geometry: Option<(Rect, Rect)>,
289) {
290    cx.app
291        .with_global_mut_untracked(TooltipProviderService::default, |svc, app| {
292            svc.begin_frame(app.frame_id());
293            let st = svc.current_state_mut();
294            let model = st
295                .pointer_transit_geometry_model
296                .clone()
297                .unwrap_or_else(|| {
298                    let model = app.models_mut().insert(None);
299                    st.pointer_transit_geometry_model = Some(model.clone());
300                    model
301                });
302
303            let _ = app.models_mut().update(&model, |v| *v = geometry);
304        });
305}
306
307pub fn is_pointer_in_transit<H: UiHost>(cx: &mut ElementContext<'_, H>) -> bool {
308    cx.app
309        .with_global_mut_untracked(TooltipProviderService::default, |svc, app| {
310            svc.begin_frame(app.frame_id());
311            svc.current_state().pointer_in_transit
312        })
313}
314
315pub fn set_pointer_in_transit<H: UiHost>(cx: &mut ElementContext<'_, H>, in_transit: bool) {
316    cx.app
317        .with_global_mut_untracked(TooltipProviderService::default, |svc, app| {
318            svc.begin_frame(app.frame_id());
319            let st = svc.current_state_mut();
320            if st.pointer_in_transit == in_transit {
321                return;
322            }
323            st.pointer_in_transit = in_transit;
324
325            let model = st.pointer_in_transit_model.clone().unwrap_or_else(|| {
326                let model = app.models_mut().insert(in_transit);
327                st.pointer_in_transit_model = Some(model.clone());
328                model
329            });
330
331            let _ = app.models_mut().update(&model, |v| *v = in_transit);
332        });
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use fret_app::App;
339    use fret_core::{AppWindowId, Point, Px, Rect, Size};
340    use fret_runtime::{FrameId, TickId};
341
342    fn bounds() -> Rect {
343        Rect::new(
344            Point::new(Px(0.0), Px(0.0)),
345            Size::new(Px(200.0), Px(120.0)),
346        )
347    }
348
349    #[test]
350    fn provider_stack_overrides_and_restores_config() {
351        let window = AppWindowId::default();
352        let mut app = App::new();
353        app.set_frame_id(FrameId(1));
354        app.set_tick_id(TickId(1));
355
356        let b = bounds();
357        fret_ui::elements::with_element_cx(&mut app, window, b, "test", |cx| {
358            let outer = TooltipProviderConfig::new(10, 30);
359            with_tooltip_provider(cx, outer, |cx| {
360                assert_eq!(current_config(cx), outer);
361
362                let inner = TooltipProviderConfig::new(5, 6).disable_hoverable_content(true);
363                with_tooltip_provider(cx, inner, |cx| {
364                    assert_eq!(current_config(cx), inner);
365                });
366
367                assert_eq!(current_config(cx), outer);
368            });
369
370            assert_eq!(current_config(cx), TooltipProviderConfig::default());
371        });
372    }
373
374    #[test]
375    fn delay_group_is_scoped_to_provider() {
376        let window = AppWindowId::default();
377        let mut app = App::new();
378        app.set_frame_id(FrameId(1));
379        app.set_tick_id(TickId(1));
380
381        let b = bounds();
382        fret_ui::elements::with_element_cx(&mut app, window, b, "test", |cx| {
383            let cfg = TooltipProviderConfig::new(10, 30);
384            with_tooltip_provider(cx, cfg, |cx| {
385                assert_eq!(open_delay_ticks(cx, 100), 10);
386                note_closed(cx, 120);
387                assert_eq!(open_delay_ticks(cx, 121), 0);
388                assert_eq!(open_delay_ticks(cx, 151), 10);
389            });
390        });
391    }
392
393    #[test]
394    fn duration_to_ticks_ceil_rounds_up() {
395        let dt16 = Duration::from_millis(16);
396
397        assert_eq!(duration_to_ticks_ceil(Duration::ZERO, dt16), 0);
398        assert_eq!(duration_to_ticks_ceil(Duration::from_millis(1), dt16), 1);
399        assert_eq!(duration_to_ticks_ceil(Duration::from_millis(16), dt16), 1);
400        assert_eq!(duration_to_ticks_ceil(Duration::from_millis(17), dt16), 2);
401        assert_eq!(duration_to_ticks_ceil(Duration::from_millis(160), dt16), 10);
402    }
403
404    #[test]
405    fn provider_close_delay_ticks_are_exposed_in_config() {
406        let window = AppWindowId::default();
407        let mut app = App::new();
408        app.set_frame_id(FrameId(1));
409        app.set_tick_id(TickId(1));
410
411        let b = bounds();
412        fret_ui::elements::with_element_cx(&mut app, window, b, "test", |cx| {
413            let cfg = TooltipProviderConfig::new(10, 30).close_delay_duration_ticks(7);
414            with_tooltip_provider(cx, cfg, |cx| {
415                let got = current_config(cx);
416                assert_eq!(got.delay_duration_ticks, 10);
417                assert_eq!(got.skip_delay_duration_ticks, 30);
418                assert_eq!(got.close_delay_duration_ticks, Some(7));
419            });
420        });
421    }
422
423    #[test]
424    fn provider_stack_is_cleared_each_frame() {
425        let window = AppWindowId::default();
426        let mut app = App::new();
427        app.set_frame_id(FrameId(1));
428        app.set_tick_id(TickId(1));
429
430        let b = bounds();
431        fret_ui::elements::with_element_cx(&mut app, window, b, "frame1", |cx| {
432            let cfg = TooltipProviderConfig::new(10, 30);
433            with_tooltip_provider(cx, cfg, |_cx| {});
434        });
435
436        app.set_frame_id(FrameId(2));
437        fret_ui::elements::with_element_cx(&mut app, window, b, "frame2", |cx| {
438            assert_eq!(current_config(cx), TooltipProviderConfig::default());
439        });
440    }
441
442    #[test]
443    fn note_opened_tracks_last_opened_tooltip() {
444        let window = AppWindowId::default();
445        let mut app = App::new();
446        app.set_frame_id(FrameId(1));
447        app.set_tick_id(TickId(1));
448
449        let b = bounds();
450        fret_ui::elements::with_element_cx(&mut app, window, b, "test", |cx| {
451            let cfg = TooltipProviderConfig::new(10, 30);
452            with_tooltip_provider(cx, cfg, |cx| {
453                let t1 = GlobalElementId(0x101);
454                let t2 = GlobalElementId(0x202);
455
456                assert_eq!(last_opened_tooltip(cx), None);
457                let tok1 = note_opened_tooltip(cx, t1);
458                assert_eq!(last_opened_tooltip(cx), Some((t1, tok1)));
459                let tok2 = note_opened_tooltip(cx, t2);
460                assert_eq!(last_opened_tooltip(cx), Some((t2, tok2)));
461                assert!(tok2 > tok1);
462            });
463        });
464    }
465
466    #[test]
467    fn pointer_in_transit_model_tracks_state_changes() {
468        let window = AppWindowId::default();
469        let mut app = App::new();
470        app.set_frame_id(FrameId(1));
471        app.set_tick_id(TickId(1));
472
473        let b = bounds();
474        fret_ui::elements::with_element_cx(&mut app, window, b, "test", |cx| {
475            assert!(!is_pointer_in_transit(cx));
476            let model = pointer_in_transit_model(cx);
477            assert_eq!(
478                cx.app.models().read(&model, |v| *v).ok(),
479                Some(false),
480                "expected model to reflect initial transit state"
481            );
482
483            set_pointer_in_transit(cx, true);
484            assert!(is_pointer_in_transit(cx));
485            assert_eq!(cx.app.models().read(&model, |v| *v).ok(), Some(true));
486
487            set_pointer_in_transit(cx, false);
488            assert!(!is_pointer_in_transit(cx));
489            assert_eq!(cx.app.models().read(&model, |v| *v).ok(), Some(false));
490        });
491    }
492}