Skip to main content

fret_ui_headless/
tooltip_intent.rs

1use fret_core::PointerType;
2
3/// Deterministic tooltip trigger intent gates used by shadcn/Radix recipes.
4///
5/// This concentrates the "suppress hover/focus reopen" policy so overlay recipes stay wiring-only.
6#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
7pub struct TooltipTriggerIntentGates {
8    pub has_pointer_move_opened: bool,
9    pub suppress_hover_open: bool,
10    pub suppress_focus_open: bool,
11}
12
13impl TooltipTriggerIntentGates {
14    pub fn trigger_hovered(self, hovered: bool) -> bool {
15        hovered && self.has_pointer_move_opened && !self.suppress_hover_open
16    }
17
18    pub fn trigger_focused(self, focused: bool) -> bool {
19        focused && !self.suppress_focus_open
20    }
21
22    /// Applies a hover-leave edge: it resets the "must see pointermove before opening" gate and any
23    /// hover suppression.
24    pub fn on_left_hover(mut self, left_hover: bool) -> Self {
25        if left_hover && (self.has_pointer_move_opened || self.suppress_hover_open) {
26            self.has_pointer_move_opened = false;
27            self.suppress_hover_open = false;
28        }
29        self
30    }
31
32    /// Clears focus suppression once focus is no longer on the trigger.
33    pub fn on_focus_changed(mut self, focused: bool) -> Self {
34        if !focused && self.suppress_focus_open {
35            self.suppress_focus_open = false;
36        }
37        self
38    }
39
40    /// Handles a close request and returns the updated gates plus whether the close flag should be
41    /// cleared.
42    pub fn on_close_requested(mut self, close_requested: bool, focused: bool) -> (Self, bool) {
43        if !close_requested {
44            return (self, false);
45        }
46
47        // Radix-like behavior: if a tooltip was opened via pointermove, closing it should suppress
48        // immediate hover reopen until we leave and re-enter.
49        if self.has_pointer_move_opened && !self.suppress_hover_open {
50            self.suppress_hover_open = true;
51        }
52
53        // Closing (via outside interaction, activate, escape) should suppress focus-driven reopen
54        // for the current focus session.
55        if focused && !self.suppress_focus_open {
56            self.suppress_focus_open = true;
57        }
58
59        (self, true)
60    }
61
62    /// Applies a non-touch pointer down on the trigger.
63    ///
64    /// Returns `(updated_gates, request_close_now)`.
65    pub fn on_pointer_down(mut self, pointer_type: PointerType) -> (Self, bool) {
66        let request_close = pointer_type != PointerType::Touch;
67        self.suppress_focus_open = true;
68        if self.has_pointer_move_opened {
69            self.suppress_hover_open = true;
70        }
71        (self, request_close)
72    }
73
74    /// Applies an "activate" intent on the trigger (e.g. keyboard activation).
75    ///
76    /// Returns `(updated_gates, request_close_now)`.
77    pub fn on_activate(mut self) -> (Self, bool) {
78        self.suppress_focus_open = true;
79        (self, true)
80    }
81
82    /// Applies an Escape key dismissal intent on the trigger.
83    ///
84    /// Returns `(updated_gates, request_close_now)`.
85    pub fn on_escape(mut self) -> (Self, bool) {
86        self.suppress_focus_open = true;
87        (self, true)
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn left_hover_resets_hover_gates() {
97        let st = TooltipTriggerIntentGates {
98            has_pointer_move_opened: true,
99            suppress_hover_open: true,
100            suppress_focus_open: false,
101        };
102        let out = st.on_left_hover(true);
103        assert_eq!(
104            out,
105            TooltipTriggerIntentGates {
106                has_pointer_move_opened: false,
107                suppress_hover_open: false,
108                suppress_focus_open: false,
109            }
110        );
111    }
112
113    #[test]
114    fn blur_clears_focus_suppression() {
115        let st = TooltipTriggerIntentGates {
116            has_pointer_move_opened: false,
117            suppress_hover_open: false,
118            suppress_focus_open: true,
119        };
120        assert_eq!(
121            st.on_focus_changed(false),
122            TooltipTriggerIntentGates {
123                suppress_focus_open: false,
124                ..st
125            }
126        );
127    }
128
129    #[test]
130    fn close_request_suppresses_focus_and_optionally_hover() {
131        let st = TooltipTriggerIntentGates {
132            has_pointer_move_opened: true,
133            suppress_hover_open: false,
134            suppress_focus_open: false,
135        };
136        let (out, clear) = st.on_close_requested(true, true);
137        assert!(clear);
138        assert!(out.suppress_focus_open);
139        assert!(out.suppress_hover_open);
140        assert!(out.has_pointer_move_opened);
141    }
142
143    #[test]
144    fn pointer_down_suppresses_focus_and_hover_when_pointer_opened() {
145        let st = TooltipTriggerIntentGates {
146            has_pointer_move_opened: true,
147            suppress_hover_open: false,
148            suppress_focus_open: false,
149        };
150        let (out, request_close) = st.on_pointer_down(PointerType::Mouse);
151        assert!(request_close);
152        assert!(out.suppress_focus_open);
153        assert!(out.suppress_hover_open);
154    }
155
156    #[test]
157    fn touch_pointer_down_does_not_request_close() {
158        let st = TooltipTriggerIntentGates::default();
159        let (_out, request_close) = st.on_pointer_down(PointerType::Touch);
160        assert!(!request_close);
161    }
162}