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}