Skip to main content

fret_ui_kit/primitives/
tooltip.rs

1//! Tooltip helpers (Radix `@radix-ui/react-tooltip` outcomes).
2//!
3//! Radix Tooltip composes three concerns:
4//!
5//! - Provider-scoped open delay behavior (`TooltipProvider` / "delay group")
6//! - Floating placement (`Popper`)
7//! - Dismiss / focus policy (handled by per-window overlay infrastructure in Fret)
8//!
9//! In `fret-ui-kit`, we keep the reusable delay mechanics split into:
10//!
11//! - `crate::headless::tooltip_delay_group` (pure, deterministic state machine), and
12//! - `crate::tooltip_provider` (provider stack service for declarative trees).
13//!
14//! This module is the Radix-named facade that re-exports the pieces under a single entry point.
15
16pub 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
47/// Stamps Radix-like trigger relationships:
48/// - `described_by_element` mirrors `aria-describedby` (by element id).
49///
50/// In Radix Tooltip, the trigger advertises the tooltip content by id. In Fret we model this via
51/// a portable element-id relationship that resolves into `SemanticsNode.described_by` when the
52/// tooltip content is mounted.
53pub 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
61/// Stable per-overlay root naming convention for tooltip overlays.
62pub 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
78/// Compute Radix-like "tooltip popper vars" (`--radix-tooltip-*`) for recipes.
79///
80/// Upstream Radix re-namespaces these from `@radix-ui/react-popper`:
81/// - `--radix-tooltip-content-available-width`
82/// - `--radix-tooltip-content-available-height`
83/// - `--radix-tooltip-trigger-width`
84/// - `--radix-tooltip-trigger-height`
85///
86/// In Fret, we compute the same concepts as a structured return value so recipes can constrain
87/// their content without relying on CSS variables.
88pub 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    /// Overrides the provider-derived open delay (ticks).
108    pub open_delay_ticks_override: Option<u64>,
109    /// Overrides the hover-close delay (ticks).
110    pub close_delay_ticks_override: Option<u64>,
111    /// Pointer safe-hover corridor buffer.
112    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
142/// Returns a per-tooltip pointer tracking model stored in element state.
143///
144/// Tooltip pointer tracking is used to approximate Radix Tooltip's hoverable-content grace area:
145/// while open, the tooltip remains open if the pointer lies within a "safe corridor" between the
146/// trigger anchor and the floating content bounds.
147pub 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
163/// Returns the per-tooltip trigger models used by Radix-aligned tooltip policies.
164///
165/// Recipes can use these models to:
166/// - derive whether hover/focus should be treated as an "open affordance",
167/// - request closes via `close_requested` (e.g. on outside press),
168/// - keep `open` as an authoritative model so view-cache synthesis can remain stable.
169pub 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
194/// Applies Radix-aligned "open affordance" gating rules for tooltip triggers.
195///
196/// This is responsible for:
197/// - suppressing immediate re-open after explicit dismiss (outside press / escape),
198/// - requiring at least one pointer move (mouse) to allow hover-open,
199/// - clearing suppression once the pointer leaves (hover edge) or focus is lost.
200pub 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
283/// Installs default Radix-aligned dismiss policies for a tooltip trigger.
284///
285/// This wires:
286/// - pointer-down (close and suppress focus/hover re-open),
287/// - activation (close and suppress focus re-open),
288/// - Escape keydown (close and suppress focus re-open).
289pub 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
359/// Wraps a tooltip trigger with a pointer-move gate that enables hover-open.
360///
361/// This follows Radix Tooltip's "require pointer move" behavior to avoid opening on incidental
362/// hover states, and also respects "pointer in transit" while the pointer moves between trigger
363/// and content (so other tooltip triggers do not open during the safe corridor).
364pub 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
440/// Returns `true` when the pointer should be considered "in transit" between the tooltip trigger
441/// and content (Radix `isPointerInTransitRef` outcome).
442///
443/// Notes:
444/// - This is a geometry-only approximation: it uses the safe-hover corridor between `anchor` and
445///   `floating`.
446/// - Unlike menus, Radix Tooltip's in-transit concept is used to *suppress other tooltip trigger
447///   opens* while the pointer moves from trigger to content.
448pub 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
623/// Installs a pointer-move observer that updates the tooltip's pointer tracking model.
624///
625/// Notes:
626/// - This is intended for hoverable-content tooltips. When `disable_hoverable_content=true`, the
627///   recipe should skip installing this observer.
628/// - Touch pointers are ignored to match Radix Tooltip's "no hover on touch" behavior.
629pub 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
647/// Updates an internal hover-intent state machine using Radix-aligned tooltip timing rules.
648///
649/// This is a reusable policy helper for recipes:
650/// - `open_delay_ticks` comes from the provider delay group, unless overridden.
651/// - Blur closes immediately.
652/// - Hoverable-content tooltips remain open while the pointer stays inside a safe-hover corridor
653///   between `anchor_bounds` and `floating_bounds`.
654pub 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/// A Radix-shaped `Tooltip` root configuration surface (open state only).
799///
800/// Radix Tooltip supports a controlled/uncontrolled `open` state (`open` + `defaultOpen`). In
801/// Fret, this root helper standardizes how recipes derive the open model before applying hover
802/// intent or provider delay-group policy.
803#[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    /// Sets the controlled `open` model (`Some`) or selects uncontrolled mode (`None`).
815    pub fn open(mut self, open: Option<Model<bool>>) -> Self {
816        self.open = open;
817        self
818    }
819
820    /// Sets the uncontrolled initial open value (Radix `defaultOpen`).
821    pub fn default_open(mut self, default_open: bool) -> Self {
822        self.default_open = default_open;
823        self
824    }
825
826    /// Returns a `Model<bool>` that behaves like Radix `useControllableState` for `open`.
827    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    /// Reads the current open value from the derived open model.
839    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
867/// Returns a `Model<bool>` that behaves like Radix `useControllableState` for `open`.
868///
869/// This is a convenience helper for authoring Radix-shaped tooltip roots:
870/// - if `controlled_open` is provided, it is used directly
871/// - otherwise an internal model is created (once) using `default_open` (Radix `defaultOpen`)
872pub 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
880/// Builds an overlay request for a Radix-style tooltip.
881pub 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
893/// Requests a tooltip overlay for the current window.
894pub 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            // Leaving hover clears the hover suppression and pointer-move gate.
1083            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            // Blurring clears the focus suppression.
1101            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}