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 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
57pub 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}