zng_wgt_tooltip/
lib.rs

1#![doc(html_favicon_url = "https://zng-ui.github.io/res/zng-logo-icon.png")]
2#![doc(html_logo_url = "https://zng-ui.github.io/res/zng-logo.png")]
3//!
4//! Tooltip widget, properties and nodes.
5//!
6//! # Crate
7//!
8#![doc = include_str!(concat!("../", std::env!("CARGO_PKG_README")))]
9#![warn(unused_extern_crates)]
10#![warn(missing_docs)]
11
12zng_wgt::enable_widget_macros!();
13
14use std::time::Duration;
15
16use zng_app::{
17    access::ACCESS_TOOLTIP_EVENT,
18    widget::{OnVarArgs, info::INTERACTIVITY_CHANGED_EVENT},
19};
20use zng_ext_input::{
21    focus::FOCUS_CHANGED_EVENT,
22    gesture::CLICK_EVENT,
23    keyboard::KEY_INPUT_EVENT,
24    mouse::{MOUSE, MOUSE_HOVERED_EVENT, MOUSE_INPUT_EVENT, MOUSE_WHEEL_EVENT},
25};
26use zng_wgt::{HitTestMode, base_color, border, corner_radius, hit_test_mode, prelude::*};
27use zng_wgt_access::{AccessRole, access_role};
28use zng_wgt_container::padding;
29use zng_wgt_fill::background_color;
30use zng_wgt_layer::{
31    AnchorMode,
32    popup::{ContextCapture, POPUP, Popup, PopupState},
33};
34use zng_wgt_style::{Style, impl_style_fn};
35
36/// Widget tooltip.
37///
38/// Any other widget can be used as tooltip, the recommended widget is the [`Tip!`] container, it provides the tooltip style. Note
39/// that if the `tip` node is not a widget even after initializing it will not be shown.
40///
41/// This property can be configured by [`tooltip_anchor`], [`tooltip_delay`], [`tooltip_interval`] and [`tooltip_duration`].
42///
43/// This tooltip only opens if the widget is enabled, see [`disabled_tooltip`] for a tooltip that only shows when the widget is disabled.
44///
45/// [`Tip!`]: struct@crate::Tip
46/// [`tooltip_anchor`]: fn@tooltip_anchor
47/// [`tooltip_delay`]: fn@tooltip_delay
48/// [`tooltip_interval`]: fn@tooltip_interval
49/// [`tooltip_duration`]: fn@tooltip_duration
50/// [`disabled_tooltip`]: fn@disabled_tooltip
51#[property(EVENT)]
52pub fn tooltip(child: impl IntoUiNode, tip: impl IntoUiNode) -> UiNode {
53    tooltip_fn(child, WidgetFn::singleton(tip))
54}
55
56/// Widget tooltip set as a widget function that is called every time the tooltip must be shown.
57///
58/// The `tip` widget function is used to instantiate a new tip widget when one needs to be shown, any widget
59/// can be used as tooltip, the recommended widget is the [`Tip!`] container, it provides the tooltip style.
60///
61/// This property can be configured by [`tooltip_anchor`], [`tooltip_delay`], [`tooltip_interval`] and [`tooltip_duration`].
62///
63/// This tooltip only opens if the widget is enabled, see [`disabled_tooltip_fn`] for a tooltip that only shows when the widget is disabled.
64///
65/// [`Tip!`]: struct@crate::Tip
66/// [`tooltip_anchor`]: fn@tooltip_anchor
67/// [`tooltip_delay`]: fn@tooltip_delay
68/// [`tooltip_interval`]: fn@tooltip_interval
69/// [`tooltip_duration`]: fn@tooltip_duration
70/// [`disabled_tooltip_fn`]: fn@disabled_tooltip_fn
71#[property(EVENT, default(WidgetFn::nil()))]
72pub fn tooltip_fn(child: impl IntoUiNode, tip: impl IntoVar<WidgetFn<TooltipArgs>>) -> UiNode {
73    tooltip_node(child, tip, false)
74}
75
76/// Disabled widget tooltip.
77///
78/// This property behaves like [`tooltip`], but the tooltip only opens if the widget is disabled.
79///
80/// [`tooltip`]: fn@tooltip
81#[property(EVENT)]
82pub fn disabled_tooltip(child: impl IntoUiNode, tip: impl IntoUiNode) -> UiNode {
83    disabled_tooltip_fn(child, WidgetFn::singleton(tip))
84}
85
86/// Disabled widget tooltip.
87///
88/// This property behaves like [`tooltip_fn`], but the tooltip only opens if the widget is disabled.
89///
90/// [`tooltip_fn`]: fn@tooltip
91#[property(EVENT, default(WidgetFn::nil()))]
92pub fn disabled_tooltip_fn(child: impl IntoUiNode, tip: impl IntoVar<WidgetFn<TooltipArgs>>) -> UiNode {
93    tooltip_node(child, tip, true)
94}
95
96fn tooltip_node(child: impl IntoUiNode, tip: impl IntoVar<WidgetFn<TooltipArgs>>, disabled_only: bool) -> UiNode {
97    let tip = tip.into_var();
98    let mut pop_state = var(PopupState::Closed).read_only();
99    let mut open_delay = None::<DeadlineVar>;
100    let mut check_cursor = false;
101    let mut auto_close = None::<DeadlineVar>;
102    let mut close_event_handles = vec![];
103    match_node(child, move |child, op| {
104        let mut open = false;
105
106        match op {
107            UiNodeOp::Init => {
108                WIDGET
109                    .sub_var(&tip)
110                    .sub_event(&MOUSE_HOVERED_EVENT)
111                    .sub_event(&ACCESS_TOOLTIP_EVENT)
112                    .sub_event(&INTERACTIVITY_CHANGED_EVENT);
113            }
114            UiNodeOp::Deinit => {
115                child.deinit();
116
117                open_delay = None;
118                auto_close = None;
119                close_event_handles.clear();
120                if let PopupState::Open(not_closed) = pop_state.get() {
121                    POPUP.force_close_id(not_closed);
122                }
123            }
124            UiNodeOp::Event { update } => {
125                child.event(update);
126
127                let mut show_hide = None;
128                let mut hover_target = None;
129
130                if let Some(args) = MOUSE_HOVERED_EVENT.on(update) {
131                    hover_target = args.target.as_ref();
132                    if disabled_only {
133                        if args.is_mouse_enter_disabled() {
134                            show_hide = Some(true);
135                            check_cursor = false;
136                        } else if args.is_mouse_leave_disabled() {
137                            show_hide = Some(false);
138                        }
139                    } else if args.is_mouse_enter() {
140                        show_hide = Some(true);
141                        check_cursor = false;
142                    } else if args.is_mouse_leave() {
143                        show_hide = Some(false);
144                    }
145                } else if let Some(args) = ACCESS_TOOLTIP_EVENT.on(update) {
146                    if disabled_only == WIDGET.info().interactivity().is_disabled() {
147                        show_hide = Some(args.visible);
148                        if args.visible {
149                            check_cursor = true;
150                        }
151                    }
152                } else if let Some(args) = INTERACTIVITY_CHANGED_EVENT.on(update)
153                    && disabled_only != args.new_interactivity(WIDGET.id()).is_disabled()
154                {
155                    show_hide = Some(false);
156                }
157
158                if let Some(show) = show_hide {
159                    let hide = !show;
160                    if open_delay.is_some() && hide {
161                        open_delay = None;
162                    }
163
164                    match pop_state.get() {
165                        PopupState::Opening => {
166                            if hide {
167                                // cancel
168                                pop_state
169                                    .on_pre_new(hn_once!(|a: &OnVarArgs<PopupState>| {
170                                        match a.value {
171                                            PopupState::Open(id) => {
172                                                POPUP.force_close_id(id);
173                                            }
174                                            PopupState::Closed => {}
175                                            PopupState::Opening => unreachable!(),
176                                        }
177                                    }))
178                                    .perm();
179                            }
180                        }
181                        PopupState::Open(id) => {
182                            if hide && !hover_target.map(|t| t.contains(id)).unwrap_or(false) {
183                                // mouse not over self and tooltip
184                                POPUP.close_id(id);
185                            }
186                        }
187                        PopupState::Closed => {
188                            if show {
189                                // open
190                                let mut delay = if hover_target.is_some()
191                                    && TOOLTIP_LAST_CLOSED
192                                        .get()
193                                        .map(|t| t.elapsed() > TOOLTIP_INTERVAL_VAR.get())
194                                        .unwrap_or(true)
195                                {
196                                    TOOLTIP_DELAY_VAR.get()
197                                } else {
198                                    Duration::ZERO
199                                };
200
201                                if let Some(open) = OPEN_TOOLTIP.get() {
202                                    POPUP.force_close_id(open);
203
204                                    // yield an update for the close deinit
205                                    // the `tooltip` property is a singleton
206                                    // that takes the widget on init, this op
207                                    // only takes the widget immediately if it
208                                    // is already deinited
209                                    delay = 1.ms();
210                                }
211
212                                if delay == Duration::ZERO {
213                                    open = true;
214                                } else {
215                                    let delay = TIMERS.deadline(delay);
216                                    delay.subscribe(UpdateOp::Update, WIDGET.id()).perm();
217                                    open_delay = Some(delay);
218                                }
219                            }
220                        }
221                    }
222                }
223            }
224            UiNodeOp::Update { .. } => {
225                if let Some(d) = &open_delay
226                    && d.get().has_elapsed()
227                {
228                    open = true;
229                    open_delay = None;
230                }
231                if let Some(d) = &auto_close
232                    && d.get().has_elapsed()
233                {
234                    auto_close = None;
235                    POPUP.close(&pop_state);
236                }
237
238                if let Some(PopupState::Closed) = pop_state.get_new() {
239                    close_event_handles.clear();
240                }
241            }
242            _ => {}
243        }
244
245        if open {
246            let anchor_id = WIDGET.id();
247            let (is_access_open, anchor_var, duration_var) =
248                if check_cursor && !MOUSE.hovered().with(|p| matches!(p, Some(p) if p.contains(anchor_id))) {
249                    (true, ACCESS_TOOLTIP_ANCHOR_VAR, ACCESS_TOOLTIP_DURATION_VAR)
250                } else {
251                    (false, TOOLTIP_ANCHOR_VAR, TOOLTIP_DURATION_VAR)
252                };
253
254            let popup = tip.get()(TooltipArgs {
255                anchor_id: WIDGET.id(),
256                disabled: disabled_only,
257            });
258            let popup = match_widget(popup, move |c, op| match op {
259                UiNodeOp::Init => {
260                    c.init();
261
262                    if let Some(mut wgt) = c.node().as_widget() {
263                        wgt.with_context(WidgetUpdateMode::Bubble, || {
264                            // if the tooltip is hit-testable and the mouse hovers it, the anchor widget
265                            // will not receive mouse-leave, because it is not the logical parent of the tooltip,
266                            // so we need to duplicate cleanup logic here.
267                            WIDGET.sub_event(&MOUSE_HOVERED_EVENT);
268
269                            let mut global = OPEN_TOOLTIP.write();
270                            if let Some(id) = global.take() {
271                                POPUP.force_close_id(id);
272                            }
273                            *global = Some(WIDGET.id());
274                        });
275                    }
276                }
277                UiNodeOp::Deinit => {
278                    if let Some(mut wgt) = c.node().as_widget() {
279                        wgt.with_context(WidgetUpdateMode::Bubble, || {
280                            let mut global = OPEN_TOOLTIP.write();
281                            if *global == Some(WIDGET.id()) {
282                                *global = None;
283                                TOOLTIP_LAST_CLOSED.set(Some(INSTANT.now()));
284                            }
285                        });
286                    }
287
288                    c.deinit();
289                }
290                UiNodeOp::Event { update } => {
291                    c.event(update);
292
293                    if let Some(args) = MOUSE_HOVERED_EVENT.on(update) {
294                        if is_access_open {
295                            return;
296                        }
297
298                        let tooltip_id = match c.node().as_widget() {
299                            Some(mut w) => w.id(),
300                            None => {
301                                // was widget on init, now is not,
302                                // this can happen if child is an `ArcNode` that was moved
303                                return;
304                            }
305                        };
306
307                        if let Some(t) = &args.target
308                            && !t.contains(anchor_id)
309                            && !t.contains(tooltip_id)
310                        {
311                            POPUP.close_id(tooltip_id);
312                        }
313                    }
314                }
315                _ => {}
316            });
317
318            pop_state = POPUP.open_config(popup, anchor_var, TOOLTIP_CONTEXT_CAPTURE_VAR.get());
319            pop_state.subscribe(UpdateOp::Update, anchor_id).perm();
320
321            let duration = duration_var.get();
322            if duration > Duration::ZERO {
323                let d = TIMERS.deadline(duration);
324                d.subscribe(UpdateOp::Update, WIDGET.id()).perm();
325                auto_close = Some(d);
326            } else {
327                auto_close = None;
328            }
329
330            let monitor_start = INSTANT.now();
331
332            // close tooltip when the user starts doing something else (after 200ms)
333            for event in [
334                MOUSE_INPUT_EVENT.as_any(),
335                CLICK_EVENT.as_any(),
336                FOCUS_CHANGED_EVENT.as_any(),
337                KEY_INPUT_EVENT.as_any(),
338                MOUSE_WHEEL_EVENT.as_any(),
339            ] {
340                close_event_handles.push(event.hook(clmv!(pop_state, |_| {
341                    let retain = monitor_start.elapsed() <= 200.ms();
342                    if !retain {
343                        POPUP.close(&pop_state);
344                    }
345                    retain
346                })));
347            }
348        }
349    })
350}
351
352/// Set the position of the tip widgets opened for the widget or its descendants.
353///
354/// Tips are inserted as [`POPUP`] when shown, this property defines how the tip layer
355/// is aligned with the anchor widget, or the cursor.
356///
357/// By default tips are aligned below the cursor position at the time they are opened.
358///
359/// This position is used when the tip opens with cursor interaction, see
360/// [`access_tooltip_anchor`] for position without the cursor.
361///
362/// This property sets the [`TOOLTIP_ANCHOR_VAR`].
363///
364/// [`access_tooltip_anchor`]: fn@access_tooltip_anchor
365/// [`POPUP`]: zng_wgt_layer::popup::POPUP::force_close
366#[property(CONTEXT, default(TOOLTIP_ANCHOR_VAR))]
367pub fn tooltip_anchor(child: impl IntoUiNode, mode: impl IntoVar<AnchorMode>) -> UiNode {
368    with_context_var(child, TOOLTIP_ANCHOR_VAR, mode)
369}
370
371/// Set the position of the tip widgets opened for the widget or its descendants without cursor interaction.
372///
373/// This position is used instead of [`tooltip_anchor`] when the tooltip is shown by commands such as [`ACCESS.show_tooltip`]
374/// and the cursor is not over the widget.
375///
376/// This property sets the [`ACCESS_TOOLTIP_ANCHOR_VAR`].
377///
378/// [`tooltip_anchor`]: fn@tooltip_anchor
379/// [`ACCESS.show_tooltip`]: zng_app::access::ACCESS::show_tooltip
380#[property(CONTEXT, default(ACCESS_TOOLTIP_ANCHOR_VAR))]
381pub fn access_tooltip_anchor(child: impl IntoUiNode, mode: impl IntoVar<AnchorMode>) -> UiNode {
382    with_context_var(child, ACCESS_TOOLTIP_ANCHOR_VAR, mode)
383}
384
385/// Defines if the tooltip captures the build/instantiate context and sets it
386/// in the node context.
387///
388/// This is disabled by default, it can be enabled to have the tooltip be affected by context properties
389/// in the anchor widget.
390///
391/// Note that updates to this property do not affect tooltips already open, just subsequent tooltips.
392///
393/// This property sets the [`TOOLTIP_CONTEXT_CAPTURE_VAR`].
394#[property(CONTEXT, default(TOOLTIP_CONTEXT_CAPTURE_VAR))]
395pub fn tooltip_context_capture(child: impl IntoUiNode, capture: impl IntoVar<ContextCapture>) -> UiNode {
396    with_context_var(child, TOOLTIP_CONTEXT_CAPTURE_VAR, capture)
397}
398
399/// Set the duration the cursor must be over the widget or its descendants before the tip widget is opened.
400///
401/// This delay applies when no other tooltip was opened within the [`tooltip_interval`] duration, otherwise the
402/// tooltip opens instantly.
403///
404/// This property sets the [`TOOLTIP_DELAY_VAR`].
405///
406/// [`tooltip_interval`]: fn@tooltip_interval
407#[property(CONTEXT, default(TOOLTIP_DELAY_VAR))]
408pub fn tooltip_delay(child: impl IntoUiNode, delay: impl IntoVar<Duration>) -> UiNode {
409    with_context_var(child, TOOLTIP_DELAY_VAR, delay)
410}
411
412/// Sets the maximum interval a second tooltip is opened instantly if a previous tip was just closed.
413///
414/// The config applies for tooltips opening on the widget or descendants, but considers previous tooltips opened on any widget.
415///
416/// This property sets the [`TOOLTIP_INTERVAL_VAR`].
417#[property(CONTEXT, default(TOOLTIP_INTERVAL_VAR))]
418pub fn tooltip_interval(child: impl IntoUiNode, interval: impl IntoVar<Duration>) -> UiNode {
419    with_context_var(child, TOOLTIP_INTERVAL_VAR, interval)
420}
421
422/// Sets the maximum duration a tooltip stays open on the widget or descendants.
423///
424/// Note that the tooltip closes at the moment the cursor leaves the widget, this duration defines the
425/// time the tooltip is closed even if the cursor is still hovering the widget. This duration is not used
426/// if the tooltip is opened without cursor interaction, in that case the [`access_tooltip_duration`] is used.
427///
428/// Zero means indefinitely, is zero by default.
429///
430/// This property sets the [`TOOLTIP_DURATION_VAR`].
431///
432/// [`access_tooltip_duration`]: fn@access_tooltip_duration
433#[property(CONTEXT, default(TOOLTIP_DURATION_VAR))]
434pub fn tooltip_duration(child: impl IntoUiNode, duration: impl IntoVar<Duration>) -> UiNode {
435    with_context_var(child, TOOLTIP_DURATION_VAR, duration)
436}
437
438/// Sets the maximum duration a tooltip stays open on the widget or descendants when it is opened without cursor interaction.
439///
440/// This duration is used instead of [`tooltip_duration`] when the tooltip is shown by commands such as [`ACCESS.show_tooltip`]
441/// and the cursor is not over the widget.
442///
443/// Zero means until [`ACCESS.hide_tooltip`], is 5 seconds by default.
444///
445/// This property sets the [`ACCESS_TOOLTIP_DURATION_VAR`].
446///
447/// [`tooltip_duration`]: fn@tooltip_duration
448/// [`ACCESS.show_tooltip`]: zng_app::access::ACCESS::show_tooltip
449/// [`ACCESS.hide_tooltip`]: zng_app::access::ACCESS::hide_tooltip
450#[property(CONTEXT, default(ACCESS_TOOLTIP_DURATION_VAR))]
451pub fn access_tooltip_duration(child: impl IntoUiNode, duration: impl IntoVar<Duration>) -> UiNode {
452    with_context_var(child, ACCESS_TOOLTIP_DURATION_VAR, duration)
453}
454
455/// Arguments for tooltip widget functions.
456#[derive(Clone, Debug)]
457#[non_exhaustive]
458pub struct TooltipArgs {
459    /// ID of the widget the tooltip is anchored to.
460    pub anchor_id: WidgetId,
461
462    /// Is `true` if the tooltip is for [`disabled_tooltip_fn`], is `false` for [`tooltip_fn`].
463    ///
464    /// [`tooltip_fn`]: fn@tooltip_fn
465    /// [`disabled_tooltip_fn`]: fn@disabled_tooltip_fn
466    pub disabled: bool,
467}
468impl TooltipArgs {
469    /// New from anchor and state.
470    pub fn new(anchor_id: impl Into<WidgetId>, disabled: bool) -> Self {
471        Self {
472            anchor_id: anchor_id.into(),
473            disabled,
474        }
475    }
476}
477
478app_local! {
479    /// Tracks the instant the last tooltip was closed on the widget.
480    ///
481    /// This value is used to implement the [`TOOLTIP_INTERVAL_VAR`], custom tooltip implementers must set it
482    /// to integrate with the [`tooltip`] implementation.
483    ///
484    /// [`tooltip`]: fn@tooltip
485    pub static TOOLTIP_LAST_CLOSED: Option<DInstant> = None;
486
487    /// Id of the current open tooltip.
488    ///
489    /// Custom tooltip implementers must take the ID and [`POPUP.force_close`] it to integrate with the [`tooltip`] implementation.
490    ///
491    /// [`tooltip`]: fn@tooltip
492    /// [`POPUP.force_close`]: zng_wgt_layer::popup::POPUP::force_close
493    pub static OPEN_TOOLTIP: Option<WidgetId> = None;
494}
495
496context_var! {
497    /// Position of the tip widget in relation to the anchor widget, when opened with cursor interaction.
498    ///
499    /// By default the tip widget is shown below the cursor.
500    pub static TOOLTIP_ANCHOR_VAR: AnchorMode = AnchorMode::tooltip();
501
502    /// Position of the tip widget in relation to the anchor widget, when opened without cursor interaction.
503    ///
504    /// By default the tip widget is shown above the widget, centered.
505    pub static ACCESS_TOOLTIP_ANCHOR_VAR: AnchorMode = AnchorMode::tooltip_shortcut();
506
507    /// Duration the cursor must be over the anchor widget before the tip widget is opened.
508    pub static TOOLTIP_DELAY_VAR: Duration = 500.ms();
509
510    /// Maximum duration from the last time a tooltip was shown that a new tooltip opens instantly.
511    pub static TOOLTIP_INTERVAL_VAR: Duration = 200.ms();
512
513    /// Maximum time a tooltip stays open, when opened with cursor interaction.
514    ///
515    /// Zero means indefinitely, is zero by default.
516    pub static TOOLTIP_DURATION_VAR: Duration = 0.ms();
517
518    /// Maximum time a tooltip stays open, when opened without cursor interaction.
519    ///
520    /// Zero means indefinitely, is `5.secs()` by default.
521    pub static ACCESS_TOOLTIP_DURATION_VAR: Duration = 5.secs();
522
523    /// Tooltip context capture.
524    ///
525    /// Is [`ContextCapture::NoCapture`] by default.
526    ///
527    ///  [`ContextCapture::NoCapture`]: zng_wgt_layer::popup::ContextCapture
528    pub static TOOLTIP_CONTEXT_CAPTURE_VAR: ContextCapture = ContextCapture::NoCapture;
529}
530
531/// A tooltip popup.
532///
533/// Can be set on the [`tooltip`] property.
534///
535/// [`tooltip`]: fn@tooltip
536#[widget($crate::Tip { ($child:expr) => { child = $child; }; })]
537pub struct Tip(Popup);
538impl Tip {
539    fn widget_intrinsic(&mut self) {
540        self.style_intrinsic(STYLE_FN_VAR, property_id!(self::style_fn));
541        widget_set! {
542            self;
543            hit_test_mode = false;
544
545            access_role = AccessRole::ToolTip;
546
547            focusable = false;
548            focus_on_init = unset!;
549        }
550    }
551
552    widget_impl! {
553        /// If the tooltip can be interacted with the mouse.
554        ///
555        /// This is disabled by default.
556        pub hit_test_mode(mode: impl IntoVar<HitTestMode>);
557    }
558}
559impl_style_fn!(Tip, DefaultStyle);
560
561/// Tip default style.
562#[widget($crate::DefaultStyle)]
563pub struct DefaultStyle(Style);
564impl DefaultStyle {
565    fn widget_intrinsic(&mut self) {
566        widget_set! {
567            self;
568            replace = true;
569            padding = (4, 6);
570            corner_radius = 3;
571            base_color = light_dark(rgb(235, 235, 235), rgb(20, 20, 20));
572            background_color = colors::BASE_COLOR_VAR.rgba();
573            zng_wgt_text::font_size = 10.pt();
574            border = {
575                widths: 1.px(),
576                sides: colors::BASE_COLOR_VAR.shade_into(1),
577            };
578        }
579    }
580}