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}