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}