zng_wgt_tooltip/
lib.rs

1#![doc(html_favicon_url = "https://raw.githubusercontent.com/zng-ui/zng/main/examples/image/res/zng-logo-icon.png")]
2#![doc(html_logo_url = "https://raw.githubusercontent.com/zng-ui/zng/main/examples/image/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, 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 UiNode, tip: impl UiNode) -> impl 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 UiNode, tip: impl IntoVar<WidgetFn<TooltipArgs>>) -> impl 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 UiNode, tip: impl UiNode) -> impl 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 UiNode, tip: impl IntoVar<WidgetFn<TooltipArgs>>) -> impl UiNode {
93    tooltip_node(child, tip, true)
94}
95
96fn tooltip_node(child: impl UiNode, tip: impl IntoVar<WidgetFn<TooltipArgs>>, disabled_only: bool) -> impl 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                    if disabled_only != args.new_interactivity(WIDGET.id()).is_disabled() {
154                        show_hide = Some(false);
155                    }
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(app_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                    if d.get().has_elapsed() {
227                        open = true;
228                        open_delay = None;
229                    }
230                }
231                if let Some(d) = &auto_close {
232                    if d.get().has_elapsed() {
233                        auto_close = None;
234                        POPUP.close(&pop_state);
235                    }
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                    c.with_context(WidgetUpdateMode::Bubble, || {
263                        // if the tooltip is hit-testable and the mouse hovers it, the anchor widget
264                        // will not receive mouse-leave, because it is not the logical parent of the tooltip,
265                        // so we need to duplicate cleanup logic here.
266                        WIDGET.sub_event(&MOUSE_HOVERED_EVENT);
267
268                        let mut global = OPEN_TOOLTIP.write();
269                        if let Some(id) = global.take() {
270                            POPUP.force_close_id(id);
271                        }
272                        *global = Some(WIDGET.id());
273                    });
274                }
275                UiNodeOp::Deinit => {
276                    c.with_context(WidgetUpdateMode::Bubble, || {
277                        let mut global = OPEN_TOOLTIP.write();
278                        if *global == Some(WIDGET.id()) {
279                            *global = None;
280                            TOOLTIP_LAST_CLOSED.set(Some(INSTANT.now()));
281                        }
282                    });
283                    c.deinit();
284                }
285                UiNodeOp::Event { update } => {
286                    c.event(update);
287
288                    if let Some(args) = MOUSE_HOVERED_EVENT.on(update) {
289                        if is_access_open {
290                            return;
291                        }
292
293                        let tooltip_id = match c.with_context(WidgetUpdateMode::Ignore, || WIDGET.id()) {
294                            Some(id) => id,
295                            None => {
296                                // was widget on init, now is not,
297                                // this can happen if child is an `ArcNode` that was moved
298                                return;
299                            }
300                        };
301
302                        if let Some(t) = &args.target {
303                            if !t.contains(anchor_id) && !t.contains(tooltip_id) {
304                                POPUP.close_id(tooltip_id);
305                            }
306                        }
307                    }
308                }
309                _ => {}
310            });
311
312            pop_state = POPUP.open_config(popup, anchor_var, TOOLTIP_CONTEXT_CAPTURE_VAR.get());
313            pop_state.subscribe(UpdateOp::Update, anchor_id).perm();
314
315            let duration = duration_var.get();
316            if duration > Duration::ZERO {
317                let d = TIMERS.deadline(duration);
318                d.subscribe(UpdateOp::Update, WIDGET.id()).perm();
319                auto_close = Some(d);
320            } else {
321                auto_close = None;
322            }
323
324            let monitor_start = INSTANT.now();
325
326            // close tooltip when the user starts doing something else (after 200ms)
327            for event in [
328                MOUSE_INPUT_EVENT.as_any(),
329                CLICK_EVENT.as_any(),
330                FOCUS_CHANGED_EVENT.as_any(),
331                KEY_INPUT_EVENT.as_any(),
332                MOUSE_WHEEL_EVENT.as_any(),
333            ] {
334                close_event_handles.push(event.hook(clmv!(pop_state, |_| {
335                    let retain = monitor_start.elapsed() <= 200.ms();
336                    if !retain {
337                        POPUP.close(&pop_state);
338                    }
339                    retain
340                })));
341            }
342        }
343    })
344}
345
346/// Set the position of the tip widgets opened for the widget or its descendants.
347///
348/// Tips are inserted as [`POPUP`] when shown, this property defines how the tip layer
349/// is aligned with the anchor widget, or the cursor.
350///
351/// By default tips are aligned below the cursor position at the time they are opened.
352///
353/// This position is used when the tip opens with cursor interaction, see
354/// [`access_tooltip_anchor`] for position without the cursor.
355///
356/// This property sets the [`TOOLTIP_ANCHOR_VAR`].
357///
358/// [`access_tooltip_anchor`]: fn@access_tooltip_anchor
359/// [`POPUP`]: zng_wgt_layer::popup::POPUP::force_close
360#[property(CONTEXT, default(TOOLTIP_ANCHOR_VAR))]
361pub fn tooltip_anchor(child: impl UiNode, mode: impl IntoVar<AnchorMode>) -> impl UiNode {
362    with_context_var(child, TOOLTIP_ANCHOR_VAR, mode)
363}
364
365/// Set the position of the tip widgets opened for the widget or its descendants without cursor interaction.
366///
367/// This position is used instead of [`tooltip_anchor`] when the tooltip is shown by commands such as [`ACCESS.show_tooltip`]
368/// and the cursor is not over the widget.
369///
370/// This property sets the [`ACCESS_TOOLTIP_ANCHOR_VAR`].
371///
372/// [`tooltip_anchor`]: fn@tooltip_anchor
373/// [`ACCESS.show_tooltip`]: zng_app::access::ACCESS::show_tooltip
374#[property(CONTEXT, default(ACCESS_TOOLTIP_ANCHOR_VAR))]
375pub fn access_tooltip_anchor(child: impl UiNode, mode: impl IntoVar<AnchorMode>) -> impl UiNode {
376    with_context_var(child, ACCESS_TOOLTIP_ANCHOR_VAR, mode)
377}
378
379/// Defines if the tooltip captures the build/instantiate context and sets it
380/// in the node context.
381///
382/// This is disabled by default, it can be enabled to have the tooltip be affected by context properties
383/// in the anchor widget.
384///
385/// Note that updates to this property do not affect tooltips already open, just subsequent tooltips.
386///
387/// This property sets the [`TOOLTIP_CONTEXT_CAPTURE_VAR`].
388#[property(CONTEXT, default(TOOLTIP_CONTEXT_CAPTURE_VAR))]
389pub fn tooltip_context_capture(child: impl UiNode, capture: impl IntoVar<ContextCapture>) -> impl UiNode {
390    with_context_var(child, TOOLTIP_CONTEXT_CAPTURE_VAR, capture)
391}
392
393/// Set the duration the cursor must be over the widget or its descendants before the tip widget is opened.
394///
395/// This delay applies when no other tooltip was opened within the [`tooltip_interval`] duration, otherwise the
396/// tooltip opens instantly.
397///
398/// This property sets the [`TOOLTIP_DELAY_VAR`].
399///
400/// [`tooltip_interval`]: fn@tooltip_interval
401#[property(CONTEXT, default(TOOLTIP_DELAY_VAR))]
402pub fn tooltip_delay(child: impl UiNode, delay: impl IntoVar<Duration>) -> impl UiNode {
403    with_context_var(child, TOOLTIP_DELAY_VAR, delay)
404}
405
406/// Sets the maximum interval a second tooltip is opened instantly if a previous tip was just closed.
407///
408/// The config applies for tooltips opening on the widget or descendants, but considers previous tooltips opened on any widget.
409///
410/// This property sets the [`TOOLTIP_INTERVAL_VAR`].
411#[property(CONTEXT, default(TOOLTIP_INTERVAL_VAR))]
412pub fn tooltip_interval(child: impl UiNode, interval: impl IntoVar<Duration>) -> impl UiNode {
413    with_context_var(child, TOOLTIP_INTERVAL_VAR, interval)
414}
415
416/// Sets the maximum duration a tooltip stays open on the widget or descendants.
417///
418/// Note that the tooltip closes at the moment the cursor leaves the widget, this duration defines the
419/// time the tooltip is closed even if the cursor is still hovering the widget. This duration is not used
420/// if the tooltip is opened without cursor interaction, in that case the [`access_tooltip_duration`] is used.
421///
422/// Zero means indefinitely, is zero by default.
423///
424/// This property sets the [`TOOLTIP_DURATION_VAR`].
425///
426/// [`access_tooltip_duration`]: fn@access_tooltip_duration
427#[property(CONTEXT, default(TOOLTIP_DURATION_VAR))]
428pub fn tooltip_duration(child: impl UiNode, duration: impl IntoVar<Duration>) -> impl UiNode {
429    with_context_var(child, TOOLTIP_DURATION_VAR, duration)
430}
431
432/// Sets the maximum duration a tooltip stays open on the widget or descendants when it is opened without cursor interaction.
433///
434/// This duration is used instead of [`tooltip_duration`] when the tooltip is shown by commands such as [`ACCESS.show_tooltip`]
435/// and the cursor is not over the widget.
436///
437/// Zero means until [`ACCESS.hide_tooltip`], is 5 seconds by default.
438///
439/// This property sets the [`ACCESS_TOOLTIP_DURATION_VAR`].
440///
441/// [`tooltip_duration`]: fn@tooltip_duration
442/// [`ACCESS.show_tooltip`]: zng_app::access::ACCESS::show_tooltip
443/// [`ACCESS.hide_tooltip`]: zng_app::access::ACCESS::hide_tooltip
444#[property(CONTEXT, default(ACCESS_TOOLTIP_DURATION_VAR))]
445pub fn access_tooltip_duration(child: impl UiNode, duration: impl IntoVar<Duration>) -> impl UiNode {
446    with_context_var(child, ACCESS_TOOLTIP_DURATION_VAR, duration)
447}
448
449/// Arguments for tooltip widget functions.
450#[derive(Clone, Debug)]
451pub struct TooltipArgs {
452    /// ID of the widget the tooltip is anchored to.
453    pub anchor_id: WidgetId,
454
455    /// Is `true` if the tooltip is for [`disabled_tooltip_fn`], is `false` for [`tooltip_fn`].
456    ///
457    /// [`tooltip_fn`]: fn@tooltip_fn
458    /// [`disabled_tooltip_fn`]: fn@disabled_tooltip_fn
459    pub disabled: bool,
460}
461
462app_local! {
463    /// Tracks the instant the last tooltip was closed on the widget.
464    ///
465    /// This value is used to implement the [`TOOLTIP_INTERVAL_VAR`], custom tooltip implementers must set it
466    /// to integrate with the [`tooltip`] implementation.
467    ///
468    /// [`tooltip`]: fn@tooltip
469    pub static TOOLTIP_LAST_CLOSED: Option<DInstant> = None;
470
471    /// Id of the current open tooltip.
472    ///
473    /// Custom tooltip implementers must take the ID and [`POPUP.force_close`] it to integrate with the [`tooltip`] implementation.
474    ///
475    /// [`tooltip`]: fn@tooltip
476    /// [`POPUP.force_close`]: zng_wgt_layer::popup::POPUP::force_close
477    pub static OPEN_TOOLTIP: Option<WidgetId> = None;
478}
479
480context_var! {
481    /// Position of the tip widget in relation to the anchor widget, when opened with cursor interaction.
482    ///
483    /// By default the tip widget is shown below the cursor.
484    pub static TOOLTIP_ANCHOR_VAR: AnchorMode = AnchorMode::tooltip();
485
486    /// Position of the tip widget in relation to the anchor widget, when opened without cursor interaction.
487    ///
488    /// By default the tip widget is shown above the widget, centered.
489    pub static ACCESS_TOOLTIP_ANCHOR_VAR: AnchorMode = AnchorMode::tooltip_shortcut();
490
491    /// Duration the cursor must be over the anchor widget before the tip widget is opened.
492    pub static TOOLTIP_DELAY_VAR: Duration = 500.ms();
493
494    /// Maximum duration from the last time a tooltip was shown that a new tooltip opens instantly.
495    pub static TOOLTIP_INTERVAL_VAR: Duration = 200.ms();
496
497    /// Maximum time a tooltip stays open, when opened with cursor interaction.
498    ///
499    /// Zero means indefinitely, is zero by default.
500    pub static TOOLTIP_DURATION_VAR: Duration = 0.ms();
501
502    /// Maximum time a tooltip stays open, when opened without cursor interaction.
503    ///
504    /// Zero means indefinitely, is `5.secs()` by default.
505    pub static ACCESS_TOOLTIP_DURATION_VAR: Duration = 5.secs();
506
507    /// Tooltip context capture.
508    ///
509    /// Is [`ContextCapture::NoCapture`] by default.
510    ///
511    ///  [`ContextCapture::NoCapture`]: zng_wgt_layer::popup::ContextCapture
512    pub static TOOLTIP_CONTEXT_CAPTURE_VAR: ContextCapture = ContextCapture::NoCapture;
513}
514
515/// A tooltip popup.
516///
517/// Can be set on the [`tooltip`] property.
518///
519/// [`tooltip`]: fn@tooltip
520#[widget($crate::Tip {
521    ($child:expr) => {
522        child = $child;
523    };
524})]
525pub struct Tip(Popup);
526impl Tip {
527    fn widget_intrinsic(&mut self) {
528        self.style_intrinsic(STYLE_FN_VAR, property_id!(self::style_fn));
529        widget_set! {
530            self;
531            hit_test_mode = false;
532
533            access_role = AccessRole::ToolTip;
534
535            focusable = false;
536            focus_on_init = unset!;
537
538            style_base_fn = style_fn!(|_| DefaultStyle!());
539        }
540    }
541
542    widget_impl! {
543        /// If the tooltip can be interacted with the mouse.
544        ///
545        /// This is disabled by default.
546        pub hit_test_mode(mode: impl IntoVar<HitTestMode>);
547    }
548}
549impl_style_fn!(Tip);
550
551/// Tip default style.
552#[widget($crate::DefaultStyle)]
553pub struct DefaultStyle(Style);
554impl DefaultStyle {
555    fn widget_intrinsic(&mut self) {
556        widget_set! {
557            self;
558            replace = true;
559            padding = (4, 6);
560            corner_radius = 3;
561            base_color = light_dark(rgb(235, 235, 235), rgb(20, 20, 20));
562            background_color = colors::BASE_COLOR_VAR.rgba();
563            zng_wgt_text::font_size = 10.pt();
564            border = {
565                widths: 1.px(),
566                sides: colors::BASE_COLOR_VAR.shade_into(1),
567            };
568        }
569    }
570}