1pub use crate::headless::tooltip_delay_group::{TooltipDelayGroupConfig, TooltipDelayGroupState};
17
18use std::cmp::Ordering;
19use std::panic::Location;
20use std::sync::Arc;
21
22use fret_core::{Point, PointerType, Px, Rect};
23use fret_ui::element::AnyElement;
24use fret_ui::elements::GlobalElementId;
25use fret_ui::{ElementContext, Invalidation, UiHost};
26
27pub use crate::tooltip_provider::{
28 TooltipProviderConfig, current_config, is_pointer_in_transit, last_opened_tooltip, note_closed,
29 note_opened_tooltip, open_delay_ticks, open_delay_ticks_with_base, pointer_in_transit_model,
30 pointer_transit_geometry_model, set_pointer_in_transit, set_pointer_transit_geometry,
31 with_tooltip_provider,
32};
33
34pub use crate::primitives::popper::{Align, ArrowOptions, LayoutDirection, Side};
35
36use crate::declarative::ModelWatchExt;
37use crate::headless::hover_intent::{HoverIntentConfig, HoverIntentState, HoverIntentUpdate};
38use crate::headless::safe_hover;
39use crate::primitives::popper;
40use crate::primitives::trigger_a11y;
41use crate::{IntoUiElement, OverlayController, OverlayPresence, OverlayRequest, collect_children};
42
43use fret_runtime::Model;
44use fret_ui::action::{ActionCx, PointerMoveCx, UiActionHost};
45use fret_ui::element::PointerRegionProps;
46
47pub fn apply_tooltip_trigger_a11y(
54 trigger: AnyElement,
55 open: bool,
56 tooltip_element: GlobalElementId,
57) -> AnyElement {
58 trigger_a11y::apply_trigger_described_by(trigger, open.then_some(tooltip_element))
59}
60
61pub fn tooltip_root_name(id: GlobalElementId) -> String {
63 OverlayController::tooltip_root_name(id)
64}
65
66#[derive(Debug, Clone, Copy, PartialEq)]
67pub struct TooltipPopperVars {
68 pub available_width: Px,
69 pub available_height: Px,
70 pub trigger_width: Px,
71 pub trigger_height: Px,
72}
73
74pub fn tooltip_popper_desired_width(outer: Rect, anchor: Rect, min_width: Px) -> Px {
75 popper::popper_desired_width(outer, anchor, min_width)
76}
77
78pub fn tooltip_popper_vars(
89 outer: Rect,
90 anchor: Rect,
91 min_width: Px,
92 placement: popper::PopperContentPlacement,
93) -> TooltipPopperVars {
94 let metrics =
95 popper::popper_available_metrics_for_placement(outer, anchor, min_width, placement);
96 TooltipPopperVars {
97 available_width: metrics.available_width,
98 available_height: metrics.available_height,
99 trigger_width: metrics.anchor_width,
100 trigger_height: metrics.anchor_height,
101 }
102}
103
104#[derive(Debug, Clone, Copy)]
105pub struct TooltipInteractionConfig {
106 pub disable_hoverable_content: bool,
107 pub open_delay_ticks_override: Option<u64>,
109 pub close_delay_ticks_override: Option<u64>,
111 pub safe_hover_buffer: Px,
113}
114
115impl Default for TooltipInteractionConfig {
116 fn default() -> Self {
117 Self {
118 disable_hoverable_content: false,
119 open_delay_ticks_override: None,
120 close_delay_ticks_override: None,
121 safe_hover_buffer: Px(5.0),
122 }
123 }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub struct TooltipInteractionUpdate {
128 pub open: bool,
129 pub wants_continuous_ticks: bool,
130}
131
132#[derive(Debug, Default, Clone, Copy)]
133struct TooltipFocusEdgeState {
134 was_focused: bool,
135}
136
137#[derive(Debug, Default, Clone, Copy)]
138struct TooltipOpenBroadcastState {
139 last_seen_open_token: u64,
140}
141
142pub fn tooltip_last_pointer_model<H: UiHost>(
148 cx: &mut ElementContext<'_, H>,
149) -> Model<Option<Point>> {
150 cx.local_model(|| None::<Point>)
151}
152
153#[derive(Clone)]
154pub struct TooltipTriggerEventModels {
155 pub has_pointer_move_opened: Model<bool>,
156 pub pointer_transit_geometry: Model<Option<(Rect, Rect)>>,
157 pub suppress_hover_open: Model<bool>,
158 pub suppress_focus_open: Model<bool>,
159 pub close_requested: Model<bool>,
160 pub open: Model<bool>,
161}
162
163pub fn tooltip_trigger_event_models<H: UiHost>(
170 cx: &mut ElementContext<'_, H>,
171) -> TooltipTriggerEventModels {
172 TooltipTriggerEventModels {
173 has_pointer_move_opened: cx.local_model_keyed("has_pointer_move_opened", || false),
174 pointer_transit_geometry: pointer_transit_geometry_model(cx),
175 suppress_hover_open: cx.local_model_keyed("suppress_hover_open", || false),
176 suppress_focus_open: cx.local_model_keyed("suppress_focus_open", || false),
177 close_requested: cx.local_model_keyed("close_requested", || false),
178 open: cx.local_model_keyed("open", || false),
179 }
180}
181
182#[derive(Debug, Default, Clone, Copy)]
183struct TooltipTriggerHoverEdgeState {
184 was_hovered: bool,
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub struct TooltipTriggerGatedSignals {
189 pub trigger_hovered: bool,
190 pub trigger_focused: bool,
191 pub force_close: bool,
192}
193
194pub fn tooltip_trigger_update_gates<H: UiHost>(
201 cx: &mut ElementContext<'_, H>,
202 hovered: bool,
203 focused: bool,
204 models: &TooltipTriggerEventModels,
205) -> TooltipTriggerGatedSignals {
206 let close_requested = cx
207 .watch_model(&models.close_requested)
208 .layout()
209 .copied()
210 .unwrap_or(false);
211 let has_pointer_move_opened = cx
212 .watch_model(&models.has_pointer_move_opened)
213 .layout()
214 .copied()
215 .unwrap_or(false);
216 let suppress_hover_open = cx
217 .watch_model(&models.suppress_hover_open)
218 .layout()
219 .copied()
220 .unwrap_or(false);
221 let suppress_focus_open = cx
222 .watch_model(&models.suppress_focus_open)
223 .layout()
224 .copied()
225 .unwrap_or(false);
226
227 let hover_edge_slot =
228 cx.keyed_slot_id_at(Location::caller(), "tooltip_trigger_hover_edge_state");
229 let left_hover = cx.state_for(
230 hover_edge_slot,
231 TooltipTriggerHoverEdgeState::default,
232 |st| {
233 let left = st.was_hovered && !hovered;
234 st.was_hovered = hovered;
235 left
236 },
237 );
238
239 if left_hover && (has_pointer_move_opened || suppress_hover_open) {
240 let _ = cx
241 .app
242 .models_mut()
243 .update(&models.has_pointer_move_opened, |v| *v = false);
244 let _ = cx
245 .app
246 .models_mut()
247 .update(&models.suppress_hover_open, |v| *v = false);
248 }
249
250 if !focused && suppress_focus_open {
251 let _ = cx
252 .app
253 .models_mut()
254 .update(&models.suppress_focus_open, |v| *v = false);
255 }
256
257 if close_requested {
258 if has_pointer_move_opened && !suppress_hover_open {
259 let _ = cx
260 .app
261 .models_mut()
262 .update(&models.suppress_hover_open, |v| *v = true);
263 }
264 if focused && !suppress_focus_open {
265 let _ = cx
266 .app
267 .models_mut()
268 .update(&models.suppress_focus_open, |v| *v = true);
269 }
270 let _ = cx
271 .app
272 .models_mut()
273 .update(&models.close_requested, |v| *v = false);
274 }
275
276 TooltipTriggerGatedSignals {
277 trigger_hovered: hovered && has_pointer_move_opened && !suppress_hover_open,
278 trigger_focused: focused && !suppress_focus_open,
279 force_close: close_requested,
280 }
281}
282
283pub fn tooltip_install_default_trigger_dismiss_handlers<H: UiHost>(
290 cx: &mut ElementContext<'_, H>,
291 trigger: GlobalElementId,
292 models: TooltipTriggerEventModels,
293) {
294 cx.pressable_add_on_pointer_down_for(
295 trigger,
296 Arc::new({
297 let close_requested = models.close_requested.clone();
298 let suppress_focus_open = models.suppress_focus_open.clone();
299 let has_pointer_move_opened = models.has_pointer_move_opened.clone();
300 let suppress_hover_open = models.suppress_hover_open.clone();
301 move |host, acx, down| {
302 if down.pointer_type != PointerType::Touch {
303 let _ = host.models_mut().update(&close_requested, |v| *v = true);
304 }
305 let _ = host
306 .models_mut()
307 .update(&suppress_focus_open, |v| *v = true);
308 let gate = host
309 .models_mut()
310 .read(&has_pointer_move_opened, |v| *v)
311 .ok()
312 .unwrap_or(false);
313 if gate {
314 let _ = host
315 .models_mut()
316 .update(&suppress_hover_open, |v| *v = true);
317 }
318 host.request_redraw(acx.window);
319 fret_ui::action::PressablePointerDownResult::Continue
320 }
321 }),
322 );
323
324 cx.pressable_add_on_activate_for(
325 trigger,
326 Arc::new({
327 let close_requested = models.close_requested.clone();
328 let suppress_focus_open = models.suppress_focus_open.clone();
329 move |host, acx, _reason| {
330 let _ = host.models_mut().update(&close_requested, |v| *v = true);
331 let _ = host
332 .models_mut()
333 .update(&suppress_focus_open, |v| *v = true);
334 host.request_redraw(acx.window);
335 }
336 }),
337 );
338
339 cx.key_add_on_key_down_for(
340 trigger,
341 Arc::new({
342 let close_requested = models.close_requested.clone();
343 let suppress_focus_open = models.suppress_focus_open.clone();
344 move |host, acx, down| {
345 if down.repeat || down.key != fret_core::KeyCode::Escape {
346 return false;
347 }
348 let _ = host.models_mut().update(&close_requested, |v| *v = true);
349 let _ = host
350 .models_mut()
351 .update(&suppress_focus_open, |v| *v = true);
352 host.request_redraw(acx.window);
353 true
354 }
355 }),
356 );
357}
358
359pub fn tooltip_wrap_trigger_with_pointer_move_open_gate<H: UiHost>(
365 cx: &mut ElementContext<'_, H>,
366 trigger: AnyElement,
367 models: TooltipTriggerEventModels,
368 pointer_in_transit_buffer: Px,
369) -> AnyElement {
370 cx.pointer_region(PointerRegionProps::default(), move |cx| {
371 cx.pointer_region_on_pointer_move(Arc::new({
372 let has_pointer_move_opened = models.has_pointer_move_opened.clone();
373 let pointer_transit_geometry = models.pointer_transit_geometry.clone();
374 move |host, acx, mv| {
375 if mv.pointer_type == PointerType::Touch {
376 return false;
377 }
378
379 let geometry = host
380 .models_mut()
381 .read(&pointer_transit_geometry, |v| *v)
382 .ok()
383 .flatten();
384 if let Some((anchor, floating)) = geometry
385 && tooltip_pointer_in_transit(
386 mv.position,
387 anchor,
388 floating,
389 pointer_in_transit_buffer,
390 )
391 {
392 return false;
393 }
394
395 let already = host
396 .models_mut()
397 .read(&has_pointer_move_opened, |v| *v)
398 .ok()
399 .unwrap_or(false);
400 if !already {
401 let _ = host
402 .models_mut()
403 .update(&has_pointer_move_opened, |v| *v = true);
404 host.request_redraw(acx.window);
405 }
406 false
407 }
408 }));
409
410 vec![trigger]
411 })
412}
413
414fn tooltip_floating_side(anchor: Rect, floating: Rect) -> Option<fret_ui::overlay_placement::Side> {
415 let anchor_left = anchor.origin.x.0;
416 let anchor_right = anchor_left + anchor.size.width.0;
417 let anchor_top = anchor.origin.y.0;
418 let anchor_bottom = anchor_top + anchor.size.height.0;
419
420 let floating_left = floating.origin.x.0;
421 let floating_right = floating_left + floating.size.width.0;
422 let floating_top = floating.origin.y.0;
423 let floating_bottom = floating_top + floating.size.height.0;
424
425 if floating_left >= anchor_right {
426 return Some(fret_ui::overlay_placement::Side::Right);
427 }
428 if floating_right <= anchor_left {
429 return Some(fret_ui::overlay_placement::Side::Left);
430 }
431 if floating_bottom <= anchor_top {
432 return Some(fret_ui::overlay_placement::Side::Top);
433 }
434 if floating_top >= anchor_bottom {
435 return Some(fret_ui::overlay_placement::Side::Bottom);
436 }
437 None
438}
439
440pub fn tooltip_pointer_in_transit(
449 position: Point,
450 anchor: Rect,
451 floating: Rect,
452 buffer: Px,
453) -> bool {
454 if anchor.contains(position) || floating.contains(position) {
455 return false;
456 }
457
458 let Some(exit_side) = tooltip_floating_side(anchor, floating) else {
459 return false;
460 };
461
462 let exit_point = tooltip_project_exit_point(anchor, position, exit_side);
463 let padding = buffer;
464 let exit_points = tooltip_padded_exit_points(exit_point, exit_side, padding);
465
466 let mut points = Vec::with_capacity(6);
467 points.extend_from_slice(&exit_points);
468 points.extend_from_slice(&tooltip_rect_points(floating));
469
470 let hull = tooltip_convex_hull(&mut points);
471 tooltip_point_in_polygon(position, &hull)
472}
473
474fn tooltip_rect_points(rect: Rect) -> [Point; 4] {
475 let left = rect.origin.x;
476 let top = rect.origin.y;
477 let right = rect.origin.x + rect.size.width;
478 let bottom = rect.origin.y + rect.size.height;
479 [
480 Point::new(left, top),
481 Point::new(right, top),
482 Point::new(right, bottom),
483 Point::new(left, bottom),
484 ]
485}
486
487fn tooltip_clamp(v: Px, min: Px, max: Px) -> Px {
488 Px(v.0.max(min.0).min(max.0))
489}
490
491fn tooltip_project_exit_point(
492 anchor: Rect,
493 position: Point,
494 side: fret_ui::overlay_placement::Side,
495) -> Point {
496 let left = anchor.origin.x;
497 let top = anchor.origin.y;
498 let right = anchor.origin.x + anchor.size.width;
499 let bottom = anchor.origin.y + anchor.size.height;
500
501 match side {
502 fret_ui::overlay_placement::Side::Right => {
503 Point::new(right, tooltip_clamp(position.y, top, bottom))
504 }
505 fret_ui::overlay_placement::Side::Left => {
506 Point::new(left, tooltip_clamp(position.y, top, bottom))
507 }
508 fret_ui::overlay_placement::Side::Top => {
509 Point::new(tooltip_clamp(position.x, left, right), top)
510 }
511 fret_ui::overlay_placement::Side::Bottom => {
512 Point::new(tooltip_clamp(position.x, left, right), bottom)
513 }
514 }
515}
516
517fn tooltip_padded_exit_points(
518 exit_point: Point,
519 side: fret_ui::overlay_placement::Side,
520 padding: Px,
521) -> [Point; 2] {
522 match side {
523 fret_ui::overlay_placement::Side::Top => [
524 Point::new(exit_point.x - padding, exit_point.y + padding),
525 Point::new(exit_point.x + padding, exit_point.y + padding),
526 ],
527 fret_ui::overlay_placement::Side::Bottom => [
528 Point::new(exit_point.x - padding, exit_point.y - padding),
529 Point::new(exit_point.x + padding, exit_point.y - padding),
530 ],
531 fret_ui::overlay_placement::Side::Left => [
532 Point::new(exit_point.x + padding, exit_point.y - padding),
533 Point::new(exit_point.x + padding, exit_point.y + padding),
534 ],
535 fret_ui::overlay_placement::Side::Right => [
536 Point::new(exit_point.x - padding, exit_point.y - padding),
537 Point::new(exit_point.x - padding, exit_point.y + padding),
538 ],
539 }
540}
541
542fn tooltip_point_in_polygon(point: Point, polygon: &[Point]) -> bool {
543 if polygon.len() < 3 {
544 return false;
545 }
546
547 let x = point.x.0;
548 let y = point.y.0;
549 let mut inside = false;
550 let mut j = polygon.len() - 1;
551
552 for i in 0..polygon.len() {
553 let xi = polygon[i].x.0;
554 let yi = polygon[i].y.0;
555 let xj = polygon[j].x.0;
556 let yj = polygon[j].y.0;
557
558 let intersect = (yi > y) != (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi;
559 if intersect {
560 inside = !inside;
561 }
562 j = i;
563 }
564
565 inside
566}
567
568fn tooltip_cross(o: Point, a: Point, b: Point) -> f32 {
569 (a.x.0 - o.x.0) * (b.y.0 - o.y.0) - (a.y.0 - o.y.0) * (b.x.0 - o.x.0)
570}
571
572fn tooltip_convex_hull(points: &mut [Point]) -> Vec<Point> {
573 if points.len() <= 1 {
574 return points.to_vec();
575 }
576
577 points.sort_by(|a, b| {
578 a.x.0
579 .partial_cmp(&b.x.0)
580 .unwrap_or(Ordering::Equal)
581 .then_with(|| a.y.0.partial_cmp(&b.y.0).unwrap_or(Ordering::Equal))
582 });
583
584 let mut lower: Vec<Point> = Vec::new();
585 for &p in points.iter() {
586 while lower.len() >= 2 {
587 let len = lower.len();
588 if tooltip_cross(lower[len - 2], lower[len - 1], p) <= 0.0 {
589 lower.pop();
590 } else {
591 break;
592 }
593 }
594 lower.push(p);
595 }
596 lower.pop();
597
598 let mut upper: Vec<Point> = Vec::new();
599 for &p in points.iter().rev() {
600 while upper.len() >= 2 {
601 let len = upper.len();
602 if tooltip_cross(upper[len - 2], upper[len - 1], p) <= 0.0 {
603 upper.pop();
604 } else {
605 break;
606 }
607 }
608 upper.push(p);
609 }
610 upper.pop();
611
612 if lower.len() == 1
613 && upper.len() == 1
614 && lower[0].x.0 == upper[0].x.0
615 && lower[0].y.0 == upper[0].y.0
616 {
617 lower
618 } else {
619 lower.into_iter().chain(upper).collect()
620 }
621}
622
623pub fn tooltip_install_pointer_move_tracker(
630 request: &mut OverlayRequest,
631 last_pointer: Model<Option<Point>>,
632) {
633 let last_pointer = last_pointer.clone();
634 request.dismissible_on_pointer_move = Some(Arc::new(
635 move |host: &mut dyn UiActionHost, _acx: ActionCx, mv: PointerMoveCx| {
636 if mv.pointer_type == PointerType::Touch {
637 return false;
638 }
639 let _ = host
640 .models_mut()
641 .update(&last_pointer, |v| *v = Some(mv.position));
642 false
643 },
644 ));
645}
646
647pub fn tooltip_update_interaction<H: UiHost>(
655 cx: &mut ElementContext<'_, H>,
656 trigger_hovered: bool,
657 trigger_focused: bool,
658 force_close: bool,
659 last_pointer: Model<Option<Point>>,
660 anchor_bounds: Option<Rect>,
661 floating_bounds: Option<Rect>,
662 cfg: TooltipInteractionConfig,
663) -> TooltipInteractionUpdate {
664 let tooltip_id = cx.root_id();
665 let open_broadcast_slot =
666 cx.keyed_slot_id_at(Location::caller(), "tooltip_open_broadcast_state");
667 let hover_intent_slot = cx.keyed_slot_id_at(Location::caller(), "tooltip_hover_intent_state");
668 let focus_edge_slot = cx.keyed_slot_id_at(Location::caller(), "tooltip_focus_edge_state");
669 let (last_id, token) = last_opened_tooltip(cx).unwrap_or((tooltip_id, 0));
670 let should_close_because_other_opened = cx.state_for(
671 open_broadcast_slot,
672 TooltipOpenBroadcastState::default,
673 |st| {
674 let should_close = token > st.last_seen_open_token && last_id != tooltip_id;
675 st.last_seen_open_token = token;
676 should_close
677 },
678 );
679 if should_close_because_other_opened {
680 cx.state_for(hover_intent_slot, HoverIntentState::default, |st| {
681 st.set_open(false)
682 });
683 }
684
685 let now = cx.app.frame_id().0;
686
687 let was_open = cx.state_for(hover_intent_slot, HoverIntentState::default, |st| {
688 st.is_open()
689 });
690
691 let (close_delay_ticks, blurred) =
692 cx.state_for(focus_edge_slot, TooltipFocusEdgeState::default, |st| {
693 let was = st.was_focused;
694 st.was_focused = trigger_focused;
695 let blurred = was && !trigger_focused;
696
697 let close_delay_ticks = if blurred || trigger_focused {
698 0
699 } else {
700 cfg.close_delay_ticks_override.unwrap_or(0)
701 };
702
703 (close_delay_ticks, blurred)
704 });
705
706 let open_delay_ticks = if trigger_focused {
707 0
708 } else if let Some(base_delay) = cfg.open_delay_ticks_override {
709 open_delay_ticks_with_base(cx, now, base_delay)
710 } else {
711 open_delay_ticks(cx, now)
712 };
713
714 let intent_cfg = HoverIntentConfig::new(open_delay_ticks, close_delay_ticks);
715
716 if force_close {
717 cx.state_for(hover_intent_slot, HoverIntentState::default, |st| {
718 st.set_open(false)
719 });
720 if was_open {
721 note_closed(cx, now);
722 }
723 if was_open {
724 set_pointer_in_transit(cx, false);
725 set_pointer_transit_geometry(cx, None);
726 }
727 return TooltipInteractionUpdate {
728 open: false,
729 wants_continuous_ticks: false,
730 };
731 }
732
733 if was_open && !cfg.disable_hoverable_content && !blurred {
734 cx.observe_model(&last_pointer, Invalidation::Paint);
735 }
736
737 let pointer_safe = if was_open && !cfg.disable_hoverable_content && !blurred {
738 let pointer = cx.app.models().read(&last_pointer, |v| *v).ok().flatten();
739 match (pointer, anchor_bounds, floating_bounds) {
740 (Some(pointer), Some(anchor), Some(floating)) => {
741 safe_hover::safe_hover_contains(pointer, anchor, floating, cfg.safe_hover_buffer)
742 }
743 _ => false,
744 }
745 } else {
746 false
747 };
748
749 let HoverIntentUpdate {
750 open,
751 wants_continuous_ticks,
752 } = cx.state_for(hover_intent_slot, HoverIntentState::default, |st| {
753 st.update(
754 trigger_hovered || trigger_focused || pointer_safe,
755 now,
756 intent_cfg,
757 )
758 });
759
760 if !was_open && open {
761 let token = note_opened_tooltip(cx, tooltip_id);
762 cx.state_for(
763 open_broadcast_slot,
764 TooltipOpenBroadcastState::default,
765 |st| {
766 st.last_seen_open_token = token;
767 },
768 );
769 }
770
771 if was_open && !open {
772 note_closed(cx, now);
773 }
774
775 if open {
776 set_pointer_in_transit(cx, pointer_safe);
777 if cfg.disable_hoverable_content || blurred {
778 set_pointer_transit_geometry(cx, None);
779 } else {
780 match (anchor_bounds, floating_bounds) {
781 (Some(anchor), Some(floating)) => {
782 set_pointer_transit_geometry(cx, Some((anchor, floating)));
783 }
784 _ => set_pointer_transit_geometry(cx, None),
785 }
786 }
787 } else if was_open {
788 set_pointer_in_transit(cx, false);
789 set_pointer_transit_geometry(cx, None);
790 }
791
792 TooltipInteractionUpdate {
793 open,
794 wants_continuous_ticks,
795 }
796}
797
798#[derive(Debug, Clone, Default)]
804pub struct TooltipRoot {
805 open: Option<Model<bool>>,
806 default_open: bool,
807}
808
809impl TooltipRoot {
810 pub fn new() -> Self {
811 Self::default()
812 }
813
814 pub fn open(mut self, open: Option<Model<bool>>) -> Self {
816 self.open = open;
817 self
818 }
819
820 pub fn default_open(mut self, default_open: bool) -> Self {
822 self.default_open = default_open;
823 self
824 }
825
826 pub fn use_open_model<H: UiHost>(
828 &self,
829 cx: &mut ElementContext<'_, H>,
830 ) -> crate::primitives::controllable_state::ControllableModel<bool> {
831 tooltip_use_open_model(cx, self.open.clone(), || self.default_open)
832 }
833
834 pub fn open_model<H: UiHost>(&self, cx: &mut ElementContext<'_, H>) -> Model<bool> {
835 self.use_open_model(cx).model()
836 }
837
838 pub fn is_open<H: UiHost>(&self, cx: &mut ElementContext<'_, H>) -> bool {
840 let open_model = self.open_model(cx);
841 cx.watch_model(&open_model)
842 .layout()
843 .copied()
844 .unwrap_or(false)
845 }
846
847 pub fn request<H: UiHost, I, T>(
848 &self,
849 cx: &mut ElementContext<'_, H>,
850 id: GlobalElementId,
851 presence: OverlayPresence,
852 children: I,
853 ) -> OverlayRequest
854 where
855 I: IntoIterator<Item = T>,
856 T: IntoUiElement<H>,
857 {
858 tooltip_request(
859 id,
860 self.open_model(cx),
861 presence,
862 collect_children(cx, children),
863 )
864 }
865}
866
867pub fn tooltip_use_open_model<H: UiHost>(
873 cx: &mut ElementContext<'_, H>,
874 controlled_open: Option<Model<bool>>,
875 default_open: impl FnOnce() -> bool,
876) -> crate::primitives::controllable_state::ControllableModel<bool> {
877 crate::primitives::open_state::open_use_model(cx, controlled_open, default_open)
878}
879
880pub fn tooltip_request(
882 id: GlobalElementId,
883 open: Model<bool>,
884 presence: OverlayPresence,
885 children: impl IntoIterator<Item = AnyElement>,
886) -> OverlayRequest {
887 let children: Vec<AnyElement> = children.into_iter().collect();
888 let mut request = OverlayRequest::tooltip(id, open, presence, children);
889 request.root_name = Some(tooltip_root_name(id));
890 request
891}
892
893pub fn request_tooltip<H: UiHost>(cx: &mut ElementContext<'_, H>, request: OverlayRequest) {
895 OverlayController::request(cx, request);
896}
897
898#[cfg(test)]
899mod tests {
900 use super::*;
901
902 use fret_app::App;
903 use fret_core::Point as CorePoint;
904 use fret_core::Size;
905 use fret_ui::element::{ElementKind, LayoutStyle, PressableProps, SemanticsProps};
906
907 #[test]
908 fn tooltip_root_open_model_uses_controlled_model() {
909 let window = Default::default();
910 let mut app = App::new();
911
912 let controlled = app.models_mut().insert(true);
913 fret_ui::elements::with_element_cx(&mut app, window, Default::default(), "test", |cx| {
914 let root = TooltipRoot::new()
915 .open(Some(controlled.clone()))
916 .default_open(false);
917 assert_eq!(root.open_model(cx), controlled);
918 });
919 }
920
921 #[test]
922 fn tooltip_request_sets_default_root_name() {
923 let mut app = App::new();
924 let open = app.models_mut().insert(true);
925 fret_ui::elements::with_element_cx(
926 &mut app,
927 Default::default(),
928 Default::default(),
929 "test",
930 move |_cx| {
931 let id = GlobalElementId(0x123);
932 let req =
933 tooltip_request(id, open.clone(), OverlayPresence::instant(true), Vec::new());
934 let expected = tooltip_root_name(id);
935 assert_eq!(req.root_name.as_deref(), Some(expected.as_str()));
936 },
937 );
938 }
939
940 #[test]
941 fn apply_tooltip_trigger_a11y_sets_described_by_on_pressable() {
942 let window = Default::default();
943 let mut app = App::new();
944 fret_ui::elements::with_element_cx(&mut app, window, Default::default(), "test", |cx| {
945 let trigger = cx.pressable(
946 PressableProps {
947 layout: LayoutStyle::default(),
948 enabled: true,
949 focusable: true,
950 ..Default::default()
951 },
952 |_cx, _st| Vec::new(),
953 );
954 let tooltip = GlobalElementId(0xbeef);
955 let trigger = apply_tooltip_trigger_a11y(trigger, true, tooltip);
956 let ElementKind::Pressable(PressableProps { a11y, .. }) = &trigger.kind else {
957 panic!("expected pressable");
958 };
959 assert_eq!(a11y.described_by_element, Some(tooltip.0));
960 });
961 }
962
963 #[test]
964 fn apply_tooltip_trigger_a11y_sets_described_by_on_semantics() {
965 let window = Default::default();
966 let mut app = App::new();
967 fret_ui::elements::with_element_cx(&mut app, window, Default::default(), "test", |cx| {
968 let trigger = cx.semantics(SemanticsProps::default(), |_cx| Vec::new());
969 let tooltip = GlobalElementId(0xbeef);
970 let trigger = apply_tooltip_trigger_a11y(trigger, true, tooltip);
971 let ElementKind::Semantics(props) = &trigger.kind else {
972 panic!("expected semantics");
973 };
974 assert_eq!(props.described_by_element, Some(tooltip.0));
975 });
976 }
977
978 #[test]
979 fn apply_tooltip_trigger_a11y_clears_described_by_when_closed() {
980 let window = Default::default();
981 let mut app = App::new();
982 fret_ui::elements::with_element_cx(&mut app, window, Default::default(), "test", |cx| {
983 let trigger = cx.pressable(
984 PressableProps {
985 layout: LayoutStyle::default(),
986 enabled: true,
987 focusable: true,
988 ..Default::default()
989 },
990 |_cx, _st| Vec::new(),
991 );
992 let tooltip = GlobalElementId(0xbeef);
993 let trigger = apply_tooltip_trigger_a11y(trigger, false, tooltip);
994 let ElementKind::Pressable(PressableProps { a11y, .. }) = &trigger.kind else {
995 panic!("expected pressable");
996 };
997 assert_eq!(a11y.described_by_element, None);
998 });
999 }
1000
1001 #[test]
1002 fn tooltip_popper_vars_available_height_tracks_flipped_side_space() {
1003 let outer = Rect::new(
1004 CorePoint::new(Px(0.0), Px(0.0)),
1005 Size::new(Px(100.0), Px(100.0)),
1006 );
1007 let anchor = Rect::new(
1008 CorePoint::new(Px(10.0), Px(70.0)),
1009 Size::new(Px(30.0), Px(10.0)),
1010 );
1011
1012 let placement = popper::PopperContentPlacement::new(
1013 popper::LayoutDirection::Ltr,
1014 popper::Side::Bottom,
1015 popper::Align::Start,
1016 Px(0.0),
1017 );
1018 let vars = tooltip_popper_vars(outer, anchor, Px(0.0), placement);
1019 assert!(vars.available_height.0 > 60.0 && vars.available_height.0 < 80.0);
1020 }
1021
1022 #[test]
1023 fn tooltip_trigger_gate_requires_pointer_move_before_hover_open() {
1024 let window = Default::default();
1025 let mut app = App::new();
1026
1027 fret_ui::elements::with_element_cx(&mut app, window, Default::default(), "test", |cx| {
1028 let models = tooltip_trigger_event_models(cx);
1029
1030 let out = tooltip_trigger_update_gates(cx, true, false, &models);
1031 assert!(
1032 !out.trigger_hovered,
1033 "expected hover gated before pointer move"
1034 );
1035
1036 let _ = cx
1037 .app
1038 .models_mut()
1039 .update(&models.has_pointer_move_opened, |v| *v = true);
1040 let out = tooltip_trigger_update_gates(cx, true, false, &models);
1041 assert!(
1042 out.trigger_hovered,
1043 "expected hover allowed after pointer move"
1044 );
1045
1046 let _ = tooltip_trigger_update_gates(cx, false, false, &models);
1047 let moved = cx
1048 .app
1049 .models()
1050 .read(&models.has_pointer_move_opened, |v| *v)
1051 .ok()
1052 .unwrap_or(true);
1053 assert!(!moved, "expected hover leave to clear pointer-move gate");
1054 });
1055 }
1056
1057 #[test]
1058 fn tooltip_trigger_suppresses_reopen_after_close_request_until_leave_and_blur() {
1059 let window = Default::default();
1060 let mut app = App::new();
1061
1062 fret_ui::elements::with_element_cx(&mut app, window, Default::default(), "test", |cx| {
1063 let models = tooltip_trigger_event_models(cx);
1064
1065 let _ = cx
1066 .app
1067 .models_mut()
1068 .update(&models.has_pointer_move_opened, |v| *v = true);
1069 let _ = cx
1070 .app
1071 .models_mut()
1072 .update(&models.close_requested, |v| *v = true);
1073
1074 let out = tooltip_trigger_update_gates(cx, true, true, &models);
1075 assert!(out.force_close);
1076
1077 let out = tooltip_trigger_update_gates(cx, true, true, &models);
1078 assert!(!out.force_close);
1079 assert!(!out.trigger_hovered);
1080 assert!(!out.trigger_focused);
1081
1082 let _ = tooltip_trigger_update_gates(cx, false, true, &models);
1084 let suppress_hover = cx
1085 .app
1086 .models()
1087 .read(&models.suppress_hover_open, |v| *v)
1088 .ok()
1089 .unwrap_or(false);
1090 assert!(!suppress_hover);
1091
1092 let moved = cx
1093 .app
1094 .models()
1095 .read(&models.has_pointer_move_opened, |v| *v)
1096 .ok()
1097 .unwrap_or(true);
1098 assert!(!moved);
1099
1100 let _ = tooltip_trigger_update_gates(cx, false, false, &models);
1102 let suppress_focus = cx
1103 .app
1104 .models()
1105 .read(&models.suppress_focus_open, |v| *v)
1106 .ok()
1107 .unwrap_or(false);
1108 assert!(!suppress_focus);
1109 });
1110 }
1111}