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