Skip to main content

ferroui_controls/
tool_tip_service.rs

1use crate::tool_tip::{is_tool_tip, same_value};
2use crate::{Control, IToolTipService, ToolTip};
3use ferroui_base::input::raw::{IRawInputEventArgs, RawPointerEventArgs, RawPointerEventType};
4use ferroui_base::input::{IInputManager, IInputRoot, InputElement, PointerEventArgs};
5use ferroui_base::reactive::{AnonymousObserver, CompositeDisposable, IDisposable};
6use ferroui_base::threading::{Dispatcher, DispatcherPriority, DispatcherTimer};
7use ferroui_base::{BoxedValue, FerroPropertyChangedEventArgs, Ref, Visual, WeakRef};
8use std::cell::{Cell, RefCell};
9use std::rc::{Rc, Weak};
10use std::time::Duration;
11
12/// Handles [`ToolTip`] interaction with controls (internal upstream).
13pub struct ToolTipService {
14    this: Weak<ToolTipService>,
15    subscriptions: CompositeDisposable,
16
17    /// The control whose tooltip is shown or about to be shown. The service
18    /// does not keep the control alive.
19    tip_control: RefCell<Option<WeakRef<Control>>>,
20    pointer_over_tip: Cell<bool>,
21    pointer_over_owner: Cell<bool>,
22    pending_close_id: Cell<i32>,
23    /// The time the last tooltip closed, on the clock of the dispatcher (in
24    /// milliseconds); `None` until a tooltip has closed.
25    last_tip_close_time: Cell<Option<i64>>,
26    timer: RefCell<Option<Rc<DispatcherTimer>>>,
27}
28
29impl ToolTipService {
30    /// Creates the service over the input manager whose raw input it
31    /// follows.
32    pub fn new(input_manager: &Rc<dyn IInputManager>) -> Rc<ToolTipService> {
33        let service = Rc::new_cyclic(|this: &Weak<ToolTipService>| ToolTipService {
34            this: this.clone(),
35            subscriptions: CompositeDisposable::new(),
36            tip_control: RefCell::new(None),
37            pointer_over_tip: Cell::new(false),
38            pointer_over_owner: Cell::new(false),
39            pending_close_id: Cell::new(0),
40            last_tip_close_time: Cell::new(None),
41            timer: RefCell::new(None),
42        });
43
44        let weak = Rc::downgrade(&service);
45        service.subscriptions.add(input_manager.process().subscribe(Rc::new(AnonymousObserver::new({
46            let weak = weak.clone();
47            move |e: Rc<dyn IRawInputEventArgs>| {
48                if let Some(this) = weak.upgrade() {
49                    this.input_manager_on_process(&*e);
50                }
51            }
52        }))));
53        service.subscriptions.add(ToolTip::service_enabled_property().changed().subscribe({
54            let weak = weak.clone();
55            move |args| {
56                if let Some(this) = weak.upgrade() {
57                    this.service_enabled_changed(args);
58                }
59            }
60        }));
61        service.subscriptions.add(ToolTip::tip_property().changed().subscribe(move |e| {
62            if let Some(this) = weak.upgrade() {
63                this.tip_changed(e);
64            }
65        }));
66
67        service
68    }
69
70    /// Stops the service: its timer, its pending close and its
71    /// subscriptions.
72    pub fn dispose(&self) {
73        self.stop_timer();
74        self.cancel_pending_close();
75        self.subscriptions.dispose();
76    }
77
78    fn tip_control(&self) -> Option<Ref<Control>> {
79        self.tip_control.borrow().as_ref().and_then(WeakRef::upgrade)
80    }
81
82    fn set_tip_control(&self, value: Option<&Ref<Control>>) {
83        *self.tip_control.borrow_mut() = value.map(Ref::downgrade);
84    }
85
86    fn input_manager_on_process(&self, e: &dyn IRawInputEventArgs) {
87        let Some(pointer_event) = e.downcast_ref::<RawPointerEventArgs>() else { return };
88
89        let tip_control = self.tip_control();
90        let tip_visual_root = tip_control.as_ref().and_then(|control| control.visual_root());
91        let current_tip = tip_control.as_ref().and_then(|control| control.get_value(ToolTip::tool_tip_property()));
92
93        // The input root of the tip itself, for popup windows only, not overlays.
94        let tip_popup_input_root = current_tip
95            .and_then(|tip| tip.popup_host())
96            .and_then(|host| host.as_control().get_input_root())
97            .filter(|root| root_element_visual(root) != tip_visual_root);
98
99        let is_tip_event = tip_popup_input_root.as_ref().is_some_and(|root| same_input_root(e.root(), root));
100        let is_tip_owner_window_event = root_element_visual(e.root()) == tip_visual_root;
101
102        if is_tip_event || is_tip_owner_window_event {
103            // The pointer is on one of the two windows (tip or owner) involved: remember which one, and cancel any
104            // close scheduled by a previous LeaveWindow on the other one.
105            if pointer_event.type_() != RawPointerEventType::LeaveWindow {
106                self.pointer_over_tip.set(is_tip_event);
107                self.pointer_over_owner.set(is_tip_owner_window_event);
108                self.cancel_pending_close();
109            } else if is_tip_event {
110                self.pointer_over_tip.set(false);
111            } else {
112                self.pointer_over_owner.set(false);
113            }
114        }
115
116        match pointer_event.type_() {
117            RawPointerEventType::Move => {
118                let candidate = pointer_event.input_hit_test_result().0.map(|element| element.upcast::<Visual>());
119                IToolTipService::update(self, pointer_event.root(), candidate);
120            }
121
122            RawPointerEventType::LeaveWindow => {
123                if (is_tip_event && !self.pointer_over_owner.get())
124                    || (is_tip_owner_window_event && !self.pointer_over_tip.get())
125                {
126                    if tip_popup_input_root.is_some() {
127                        // The pointer is leaving one of the two windows, but there's a chance it's going to
128                        // the other one. Schedule a close and cancel it if we receive another event.
129                        self.schedule_pending_close();
130                    } else {
131                        self.close_current_tip();
132                    }
133                }
134            }
135
136            RawPointerEventType::LeftButtonDown
137            | RawPointerEventType::RightButtonDown
138            | RawPointerEventType::MiddleButtonDown
139            | RawPointerEventType::XButton1Down
140            | RawPointerEventType::XButton2Down => {
141                // clear the tip
142                self.stop_timer();
143                if let Some(tip_control) = self.tip_control() {
144                    tip_control.clear_value(ToolTip::is_open_property());
145                }
146                self.pointer_over_tip.set(false);
147            }
148
149            _ => {}
150        }
151    }
152
153    fn schedule_pending_close(&self) {
154        let control = self.tip_control();
155        let close_id = self.pending_close_id.get().wrapping_add(1);
156        self.pending_close_id.set(close_id);
157
158        // Post below input priority: any pointer event is processed before this callback and gets a chance to cancel it.
159        let weak = self.this.clone();
160        Dispatcher::ui_thread().post_local(
161            move || {
162                let Some(this) = weak.upgrade() else { return };
163                if this.pending_close_id.get() == close_id && this.tip_control() == control {
164                    this.close_current_tip();
165                }
166            },
167            DispatcherPriority::BACKGROUND,
168        );
169    }
170
171    fn cancel_pending_close(&self) {
172        self.pending_close_id.set(self.pending_close_id.get().wrapping_add(1));
173    }
174
175    fn close_current_tip(&self) {
176        self.stop_timer();
177        if let Some(tip_control) = self.tip_control() {
178            tip_control.clear_value(ToolTip::is_open_property());
179        }
180        self.set_tip_control(None);
181        self.pointer_over_tip.set(false);
182        self.pointer_over_owner.set(false);
183    }
184
185    fn service_enabled_changed(&self, args: &FerroPropertyChangedEventArgs<'_>) {
186        let Some(tip_control) = self.tip_control() else { return };
187        let sender = args.sender().downcast_ref::<Control>();
188
189        if sender.is_some_and(|sender| sender.to_ref() == tip_control) && !ToolTip::get_service_enabled(&tip_control) {
190            self.stop_timer();
191        }
192    }
193
194    /// Called when the `Tip` property changes on a control.
195    fn tip_changed(&self, e: &FerroPropertyChangedEventArgs<'_>) {
196        let control = e.sender().downcast_ref::<Control>().expect("the tooltip properties are attached to controls");
197        let (old_value, new_value) = e.get_old_and_new_value::<Option<BoxedValue>>();
198
199        let changed = match (&old_value, &new_value) {
200            (None, None) => false,
201            (Some(old_value), Some(new_value)) => !same_value(old_value, new_value),
202            _ => true,
203        };
204
205        if ToolTip::get_is_open(control) && changed && !new_value.as_ref().is_some_and(is_tool_tip) {
206            match new_value {
207                None => self.close(control),
208                Some(new_value) => {
209                    if let Some(tip) = control.get_value(ToolTip::tool_tip_property()) {
210                        tip.set_content(Some(new_value));
211                    }
212                }
213            }
214        }
215    }
216
217    fn on_tip_control_changed(&self, old_value: Option<&Ref<Control>>, new_value: Option<&Ref<Control>>) {
218        self.stop_timer();
219
220        let mut closed_previous_tip = false; // avoid race conditions by remembering whether we closed a tooltip in the current call.
221
222        if let Some(old_value) = old_value {
223            if ToolTip::get_is_open(old_value) {
224                self.close(old_value);
225                closed_previous_tip = true;
226            }
227        }
228
229        if let Some(new_value) = new_value {
230            if !ToolTip::get_is_open(new_value) {
231                let between_show_delay = ToolTip::get_between_show_delay(new_value);
232
233                let closed_recently = || {
234                    self.last_tip_close_time.get().is_some_and(|last_tip_close_time| {
235                        Dispatcher::ui_thread().now().wrapping_sub(last_tip_close_time) <= i64::from(between_show_delay)
236                    })
237                };
238
239                let show_delay = if between_show_delay >= 0 && (closed_previous_tip || closed_recently()) {
240                    0
241                } else {
242                    ToolTip::get_show_delay(new_value)
243                };
244
245                if show_delay == 0 {
246                    self.open(new_value);
247                } else {
248                    self.start_show_timer(show_delay, new_value);
249                }
250            }
251        }
252    }
253
254    fn tool_tip_closed(&self) {
255        self.last_tip_close_time.set(Some(Dispatcher::ui_thread().now()));
256    }
257
258    fn tool_tip_pointer_exited(&self, tool_tip: &ToolTip) {
259        // The pointer has exited the tooltip. Close the tooltip unless the current tooltip source is still the
260        // adorned control.
261        if let Some(control) = tool_tip.adorned_control() {
262            if Some(&control) != self.tip_control().as_ref() {
263                self.close(&control);
264            }
265        }
266    }
267
268    fn start_show_timer(&self, show_delay: i32, control: &Ref<Control>) {
269        let Ok(show_delay) = u64::try_from(show_delay) else {
270            panic!("The show delay of a tooltip must not be negative.");
271        };
272
273        let timer = DispatcherTimer::new();
274        timer.set_interval(Duration::from_millis(show_delay));
275        timer.set_tag(Some(Control::boxed(control)));
276        // The timer belongs to the service, which stops and drops it.
277        let _ = timer.tick({
278            let weak = self.this.clone();
279            let control = control.downgrade();
280            move |_| {
281                let (Some(this), Some(control)) = (weak.upgrade(), control.upgrade()) else { return };
282                if this.timer.borrow().is_some() {
283                    this.open(&control);
284                }
285            }
286        });
287        *self.timer.borrow_mut() = Some(timer.clone());
288        timer.start();
289    }
290
291    fn open(&self, control: &Ref<Control>) {
292        self.stop_timer();
293
294        if control.is_attached_to_visual_tree() {
295            ToolTip::set_is_open(control, true);
296
297            // Value can be coerced back to false, need to double check.
298            if ToolTip::get_is_open(control) {
299                if let Some(tool_tip) = control.get_value(ToolTip::tool_tip_property()) {
300                    self.subscribe_to_tool_tip(&tool_tip);
301                }
302            }
303        }
304    }
305
306    /// Follows `tool_tip` until its popup closes: the close time is
307    /// recorded, and the pointer leaving the tooltip closes it.
308    fn subscribe_to_tool_tip(&self, tool_tip: &Ref<ToolTip>) {
309        let pointer_exited = tool_tip.add_handler(InputElement::pointer_exited_event(), {
310            let weak = self.this.clone();
311            let weak_tool_tip = tool_tip.downgrade();
312            move |_, _: &PointerEventArgs| {
313                if let (Some(this), Some(tool_tip)) = (weak.upgrade(), weak_tool_tip.upgrade()) {
314                    this.tool_tip_pointer_exited(&tool_tip);
315                }
316            }
317        });
318
319        let closed: Rc<RefCell<Option<Rc<dyn IDisposable>>>> = Rc::new(RefCell::new(None));
320        let subscription = tool_tip.closed({
321            let weak = self.this.clone();
322            let closed = closed.clone();
323            move |tool_tip| {
324                if let Some(this) = weak.upgrade() {
325                    this.tool_tip_closed();
326                }
327
328                let subscription = closed.borrow_mut().take();
329                if let Some(subscription) = subscription {
330                    subscription.dispose();
331                }
332                tool_tip.remove_handler(InputElement::pointer_exited_event(), pointer_exited);
333            }
334        });
335        *closed.borrow_mut() = Some(subscription);
336    }
337
338    fn close(&self, control: &Control) {
339        ToolTip::set_is_open(control, false);
340    }
341
342    fn stop_timer(&self) {
343        let timer = self.timer.borrow_mut().take();
344        if let Some(timer) = timer {
345            timer.stop();
346        }
347    }
348}
349
350impl IToolTipService for ToolTipService {
351    fn update(&self, root: &Rc<dyn IInputRoot>, candidate_tool_tip_host: Option<Ref<Visual>>) {
352        let tip_control = self.tip_control();
353        let current_tool_tip =
354            tip_control.as_ref().and_then(|control| control.get_value(ToolTip::tool_tip_property()));
355
356        let tool_tip_input_root = current_tool_tip
357            .as_ref()
358            .and_then(|tool_tip| tool_tip.popup_host())
359            .and_then(|host| host.hosted_visual_tree_root())
360            .and_then(|hosted_root| hosted_root.get_input_root());
361        if tool_tip_input_root.is_some_and(|tool_tip_input_root| same_input_root(root, &tool_tip_input_root)) {
362            // Don't update while the pointer is over a tooltip
363            return;
364        }
365
366        let current_tool_tip_visual: Option<Ref<Visual>> = current_tool_tip.map(Ref::upcast);
367        let mut candidate_tool_tip_host = candidate_tool_tip_host;
368
369        while let Some(candidate) = candidate_tool_tip_host.clone() {
370            // when OverlayPopupHost is in use, the tooltip is in the same window as the host control
371            if Some(&candidate) == current_tool_tip_visual.as_ref() {
372                return;
373            }
374
375            if let Some(control) = candidate.downcast_ref::<Control>() {
376                if !ToolTip::get_service_enabled(control) {
377                    return;
378                }
379
380                if ToolTip::get_tip(control).is_some()
381                    && (control.is_effectively_enabled() || ToolTip::get_show_on_disabled(control))
382                {
383                    break;
384                }
385            }
386
387            candidate_tool_tip_host = candidate.visual_parent();
388        }
389
390        let new_control = candidate_tool_tip_host.and_then(|candidate| candidate.cast::<Control>());
391
392        if new_control == tip_control {
393            return;
394        }
395
396        self.on_tip_control_changed(tip_control.as_ref(), new_control.as_ref());
397        self.set_tip_control(new_control.as_ref());
398        self.pointer_over_tip.set(false);
399        self.pointer_over_owner.set(false);
400    }
401
402    fn as_tool_tip_service(&self) -> Option<&ToolTipService> {
403        Some(self)
404    }
405}
406
407impl IDisposable for ToolTipService {
408    fn dispose(&self) {
409        ToolTipService::dispose(self)
410    }
411}
412
413/// The root element of an input root, as a visual: `None` for a root that
414/// has closed, as the event that closed a popup is still processed with the
415/// popup as its root (the reference reads a null `RootElement` there).
416fn root_element_visual(root: &Rc<dyn IInputRoot>) -> Option<Ref<Visual>> {
417    root.try_root_element().map(Ref::upcast)
418}
419
420/// Whether two input root handles are the same input root.
421fn same_input_root(a: &Rc<dyn IInputRoot>, b: &Rc<dyn IInputRoot>) -> bool {
422    std::ptr::addr_eq(Rc::as_ptr(a), Rc::as_ptr(b))
423}