agg_gui/widgets/tooltip/mod.rs
1//! `Tooltip` — a wrapper widget that shows egui-style hover help.
2//!
3//! Tooltips come in two flavours:
4//!
5//! * **Lightweight** (the default, used by dozens of call sites): text lines
6//! submitted during the widget paint pass and drawn at the end of the frame
7//! by [`crate::widget::App`] via a global queue ([`render`]). Fire-and-forget,
8//! no hit-testing.
9//!
10//! * **Interactive** (opt-in via [`Tooltip::with_interactive_content`]): the tip
11//! is a real floating UI surface hosting a child widget tree (labels,
12//! hyperlinks, a nested tooltip). It participates in the global-overlay
13//! hit-test so the pointer can move *into* it without dismissing it, and it
14//! supports one level of nesting — see [`interactive`]. This mirrors egui's
15//! `on_hover_ui` tooltips.
16//!
17//! # Usage
18//!
19//! ```ignore
20//! Tooltip::new(
21//! Box::new(Button::new("Hover me", font.clone()).on_click(|| {})),
22//! "This is a tooltip",
23//! font.clone(),
24//! )
25//! ```
26
27pub mod controller;
28mod interactive;
29mod render;
30mod timings;
31
32pub(crate) use render::{begin_tooltip_frame, paint_global_tooltips};
33pub use timings::{set_tooltip_timings, tooltip_timings, TooltipTimings};
34#[doc(hidden)]
35pub use timings::{
36 advance_tooltip_test_clock, reset_tooltip_test_state, set_tooltip_test_clock,
37};
38
39use timings::{last_tooltip_visible_at, note_tooltip_visible, tooltip_now};
40
41use std::rc::Rc;
42use std::sync::Arc;
43use std::time::Duration;
44use web_time::Instant;
45
46use crate::draw_ctx::DrawCtx;
47use crate::event::{Event, EventResult};
48use crate::geometry::{Point, Rect, Size};
49use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
50use crate::text::Font;
51use crate::widget::{current_mouse_world, Widget};
52
53use render::{submit_tooltip, TooltipRequest};
54
55/// Compile-time default initial hover delay, kept as a stable reference for
56/// tests and for interactive-tip code paths. Runtime code reads the (possibly
57/// OS-overridden) value via [`tooltip_timings`]; this equals the default there.
58const TOOLTIP_INITIAL_DELAY: Duration = timings::DEFAULT_INITIAL_DELAY;
59pub(super) const TOOLTIP_FONT_SIZE: f64 = 12.0;
60pub(super) const TOOLTIP_PAD_X: f64 = 8.0;
61pub(super) const TOOLTIP_PAD_Y: f64 = 6.0;
62pub(super) const TOOLTIP_GAP: f64 = 4.0;
63/// Extra vertical offset for pointer-anchored tooltips. They should
64/// read as attached below the cursor rather than hugging it.
65pub(super) const POINTER_TOOLTIP_EXTRA_DROP: f64 = 10.0;
66pub(super) const SCREEN_MARGIN: f64 = 4.0;
67
68#[derive(Clone)]
69pub(super) enum TooltipLineKind {
70 Text,
71 Code,
72 Link,
73}
74
75#[derive(Clone)]
76pub(super) struct TooltipLine {
77 pub text: String,
78 pub kind: TooltipLineKind,
79}
80
81/// A wrapper widget that shows a text tooltip on hover.
82pub struct Tooltip {
83 bounds: Rect,
84 /// The wrapped child widget is stored in `children[0]`.
85 children: Vec<Box<dyn Widget>>,
86 base: WidgetBase,
87
88 /// Time when the pointer entered the widget. `None` when the
89 /// pointer is outside. Wall-clock timing gives consistent tooltip
90 /// latency even when the app is not repainting continuously.
91 hover_started_at: Option<Instant>,
92 /// Whether the cursor is currently inside the widget bounds.
93 hovered: bool,
94 /// Whether this tooltip was visible on the previous paint. Used
95 /// to invalidate when the delayed tooltip appears or disappears,
96 /// not just when hover state changes.
97 tooltip_visible: bool,
98 /// Delay to apply for the current hover, captured when the pointer entered:
99 /// the full initial delay, or the shorter reshow delay if another tip was
100 /// visible within the reshow window (move-along-the-toolbar).
101 pending_delay: Duration,
102 /// When the current tip became visible, used to auto-dismiss after
103 /// [`TooltipTimings::autopop`].
104 tip_shown_at: Option<Instant>,
105 /// Suppresses (re)showing until the pointer leaves and re-enters. Set on any
106 /// mouse press over the child and on autopop, mirroring Windows: a click or a
107 /// timed-out tip stays hidden until you move off and back onto the control.
108 suppressed: bool,
109 /// Whether a mouse button is currently held; tips never show while pressed.
110 pointer_down: bool,
111 /// Last known cursor position in local coordinates.
112 cursor: Point,
113
114 font: Arc<Font>,
115 lines: Vec<TooltipLine>,
116 disabled_lines: Vec<TooltipLine>,
117 disabled_when: Option<Rc<dyn Fn() -> bool>>,
118 at_pointer: bool,
119
120 // --- Interactive-mode state (see `interactive`) ---------------------
121 /// When `true`, the tip is a hit-testable surface hosting `content`
122 /// instead of the lightweight text queue.
123 interactive: bool,
124 /// The interactive tip's child widget tree. Not part of `children`, so
125 /// it is not laid out / painted / hit-tested by the normal tree walk —
126 /// [`interactive`] manages it manually at the floating tip position.
127 content: Option<Box<dyn Widget>>,
128 /// Natural size of `content` from its last layout.
129 content_size: Size,
130 /// Latched open-state for the interactive tip. Stays open while the
131 /// pointer is over the anchor OR the tip; closes after a grace period
132 /// once it leaves both (or on Escape).
133 tip_open: bool,
134 /// Whether the pointer is currently over the interactive tip surface.
135 tip_hovered: bool,
136 /// Panel rectangle in this widget's LOCAL coordinate space, captured
137 /// during the last overlay paint and reused for hit-testing.
138 tip_panel_local: Option<Rect>,
139 /// Where `content` is painted within the panel (local coords).
140 content_origin_local: Point,
141 /// When the pointer left both anchor and tip; the tip closes once this
142 /// exceeds [`interactive::TOOLTIP_CLOSE_GRACE`].
143 close_requested_at: Option<Instant>,
144 /// Path of the content descendant the pointer last hovered, so we can
145 /// clear its hover (and any nested tooltip) when the pointer moves off.
146 last_content_path: Option<Vec<usize>>,
147}
148
149impl Tooltip {
150 /// Create a new `Tooltip` wrapping `child` with `text` as the tip message.
151 pub fn new(child: Box<dyn Widget>, text: impl Into<String>, font: Arc<Font>) -> Self {
152 Self {
153 bounds: Rect::default(),
154 children: vec![child],
155 base: WidgetBase::new(),
156 hover_started_at: None,
157 hovered: false,
158 tooltip_visible: false,
159 pending_delay: TOOLTIP_INITIAL_DELAY,
160 tip_shown_at: None,
161 suppressed: false,
162 pointer_down: false,
163 cursor: Point::ORIGIN,
164 font,
165 lines: text_to_lines(text),
166 disabled_lines: Vec::new(),
167 disabled_when: None,
168 at_pointer: true,
169 interactive: false,
170 content: None,
171 content_size: Size::new(0.0, 0.0),
172 tip_open: false,
173 tip_hovered: false,
174 tip_panel_local: None,
175 content_origin_local: Point::ORIGIN,
176 close_requested_at: None,
177 last_content_path: None,
178 }
179 }
180
181 /// Add another hover text block, matching egui's ability to chain
182 /// `.on_hover_text(...)` calls.
183 pub fn with_text(mut self, text: impl Into<String>) -> Self {
184 self.lines.extend(text_to_lines(text));
185 self
186 }
187
188 /// Add a code-styled line to the tooltip.
189 pub fn with_code_line(mut self, text: impl Into<String>) -> Self {
190 self.lines.push(TooltipLine {
191 text: text.into(),
192 kind: TooltipLineKind::Code,
193 });
194 self
195 }
196
197 /// Add a link-styled line to the tooltip. Tooltip overlays are
198 /// informational; the line is styled like a link but does not receive
199 /// pointer events.
200 pub fn with_link_line(mut self, text: impl Into<String>) -> Self {
201 self.lines.push(TooltipLine {
202 text: text.into(),
203 kind: TooltipLineKind::Link,
204 });
205 self
206 }
207
208 /// Turn this tooltip into an **interactive** surface hosting `content`
209 /// (a small widget tree of labels / hyperlinks). The lightweight text
210 /// lines are ignored while interactive. The pointer can enter the tip
211 /// without dismissing it, and a tooltip-bearing widget inside `content`
212 /// shows its own (nested) tip. Mirrors egui's `on_hover_ui`.
213 pub fn with_interactive_content(mut self, content: Box<dyn Widget>) -> Self {
214 self.interactive = true;
215 self.content = Some(content);
216 self.at_pointer = false;
217 self
218 }
219
220 /// Place the tooltip relative to the mouse cursor instead of the widget.
221 /// This is the default; kept for call-site clarity.
222 pub fn at_pointer(mut self) -> Self {
223 self.at_pointer = true;
224 self
225 }
226
227 /// Place the tooltip relative to the wrapped widget instead of the
228 /// mouse cursor.
229 pub fn at_widget(mut self) -> Self {
230 self.at_pointer = false;
231 self
232 }
233
234 /// Use alternate tooltip text while `disabled_when` returns true.
235 pub fn with_disabled_text(
236 mut self,
237 text: impl Into<String>,
238 disabled_when: impl Fn() -> bool + 'static,
239 ) -> Self {
240 self.disabled_lines = text_to_lines(text);
241 self.disabled_when = Some(Rc::new(disabled_when));
242 self
243 }
244
245 pub fn with_margin(mut self, m: Insets) -> Self {
246 self.base.margin = m;
247 self
248 }
249 pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
250 self.base.h_anchor = h;
251 self
252 }
253 pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
254 self.base.v_anchor = v;
255 self
256 }
257
258 /// Delay to apply for a hover starting now: the shorter reshow delay when a
259 /// tip was visible within the reshow window, otherwise the full initial
260 /// delay. Captured at pointer-enter into [`Self::pending_delay`].
261 fn effective_delay(&self) -> Duration {
262 let t = tooltip_timings();
263 match last_tooltip_visible_at() {
264 Some(at) if tooltip_now().saturating_duration_since(at) <= t.reshow_window() => {
265 t.reshow_delay
266 }
267 _ => t.initial_delay,
268 }
269 }
270
271 /// Whether the tip should be visible right now, ignoring autopop (handled
272 /// separately in [`Self::update_visibility`]).
273 fn should_show_tip(&self) -> bool {
274 if self.suppressed || self.pointer_down || !self.hovered {
275 return false;
276 }
277 match self.hover_started_at {
278 Some(started) => {
279 tooltip_now().saturating_duration_since(started) >= self.pending_delay
280 }
281 None => false,
282 }
283 }
284
285 /// Advance the lightweight tip's visibility state machine. Applies autopop,
286 /// flips [`Self::tooltip_visible`], warms the shared reshow window, and
287 /// schedules the wall-clock wake that drives the delayed show / auto-dismiss
288 /// without continuous repainting. Returns whether the tip is now visible.
289 ///
290 /// Split out from `paint_overlay` so the state machine is unit-testable
291 /// without a `DrawCtx`.
292 fn update_visibility(&mut self) -> bool {
293 // Auto-dismiss a tip that has sat open too long, and keep it hidden
294 // until the pointer leaves and re-enters (Windows AUTOPOP behaviour).
295 if self.tooltip_visible {
296 if let Some(shown) = self.tip_shown_at {
297 let autopop = tooltip_timings().autopop;
298 let elapsed = tooltip_now().saturating_duration_since(shown);
299 if elapsed >= autopop {
300 self.hide_tip();
301 self.suppressed = true;
302 return false;
303 }
304 // Re-arm the autopop wake every visible frame: the deadline is
305 // read-and-cleared by the host each frame, so an intervening
306 // repaint would otherwise drop it and the dismiss never fires.
307 crate::animation::request_draw_after_tagged(
308 autopop - elapsed,
309 "tooltip.wrapper.autopop_rearm",
310 );
311 }
312 }
313
314 if self.should_show_tip() {
315 if !self.tooltip_visible {
316 self.tooltip_visible = true;
317 self.tip_shown_at = Some(tooltip_now());
318 crate::animation::request_draw_tagged("tooltip.wrapper.show");
319 // Wake at the autopop deadline so a still pointer still dismisses.
320 crate::animation::request_draw_after_tagged(
321 tooltip_timings().autopop,
322 "tooltip.wrapper.autopop_arm",
323 );
324 }
325 note_tooltip_visible();
326 return true;
327 }
328
329 if self.tooltip_visible {
330 self.hide_tip();
331 } else if let Some(remaining) = self.remaining_delay() {
332 // Not yet time to show: schedule the wake at the show deadline.
333 if remaining.is_zero() {
334 crate::animation::request_draw_tagged("tooltip.wrapper.remaining_zero");
335 } else {
336 crate::animation::request_draw_after_tagged(
337 remaining,
338 "tooltip.wrapper.remaining",
339 );
340 }
341 }
342 false
343 }
344
345 /// Time left before the pending show fires, or `None` when no show is
346 /// pending (not hovered, suppressed, or a button is held).
347 fn remaining_delay(&self) -> Option<Duration> {
348 if !self.hovered || self.suppressed || self.pointer_down {
349 return None;
350 }
351 let elapsed = tooltip_now().saturating_duration_since(self.hover_started_at?);
352 Some(self.pending_delay.saturating_sub(elapsed))
353 }
354
355 /// Hide the tip and clear its display timer, invalidating so the overlay
356 /// clears on the next paint.
357 fn hide_tip(&mut self) {
358 if self.tooltip_visible {
359 crate::animation::request_draw();
360 }
361 self.tooltip_visible = false;
362 self.tip_shown_at = None;
363 }
364
365 fn active_lines(&self) -> Vec<TooltipLine> {
366 if self.disabled_when.as_ref().map(|f| f()).unwrap_or(false)
367 && !self.disabled_lines.is_empty()
368 {
369 self.disabled_lines.clone()
370 } else {
371 self.lines.clone()
372 }
373 }
374}
375
376impl Widget for Tooltip {
377 fn type_name(&self) -> &'static str {
378 "Tooltip"
379 }
380 fn bounds(&self) -> Rect {
381 self.bounds
382 }
383 fn set_bounds(&mut self, b: Rect) {
384 self.bounds = b;
385 }
386 fn children(&self) -> &[Box<dyn Widget>] {
387 &self.children
388 }
389 fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
390 &mut self.children
391 }
392
393 fn margin(&self) -> Insets {
394 self.base.margin
395 }
396 fn widget_base(&self) -> Option<&WidgetBase> {
397 Some(&self.base)
398 }
399 fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
400 Some(&mut self.base)
401 }
402 fn h_anchor(&self) -> HAnchor {
403 self.base.h_anchor
404 }
405 fn v_anchor(&self) -> VAnchor {
406 self.base.v_anchor
407 }
408
409 /// Forward the wrapped child's size constraints so the tooltip is a
410 /// transparent layout wrapper. Without this a constrained child — e.g. a
411 /// [`ComboBox`](crate::widgets::combo_box::ComboBox) whose `max_size` caps its
412 /// width — would lose that cap when wrapped and placed in a `FlexRow`, because
413 /// the row reads the *wrapper's* constraints, not the child's, and would let
414 /// the combo stretch across the whole row.
415 fn min_size(&self) -> Size {
416 self.children
417 .first()
418 .map(|c| c.min_size())
419 .unwrap_or(Size::ZERO)
420 }
421 fn max_size(&self) -> Size {
422 self.children
423 .first()
424 .map(|c| c.max_size())
425 .unwrap_or(Size::MAX)
426 }
427
428 /// Expose the tip text to the inspector and to tests, so a tooltip's presence
429 /// and message can be asserted by walking the widget tree (there is no public
430 /// downcast for `dyn Widget`).
431 fn properties(&self) -> Vec<(&'static str, String)> {
432 vec![(
433 "tooltip",
434 self.lines
435 .iter()
436 .map(|l| l.text.as_str())
437 .collect::<Vec<_>>()
438 .join("\n"),
439 )]
440 }
441
442 fn is_focusable(&self) -> bool {
443 self.children
444 .first()
445 .map(|c| c.is_focusable())
446 .unwrap_or(false)
447 }
448
449 fn layout(&mut self, available: Size) -> Size {
450 let s = if let Some(child) = self.children.first_mut() {
451 let cs = child.layout(available);
452 child.set_bounds(Rect::new(0.0, 0.0, cs.width, cs.height));
453 cs
454 } else {
455 available
456 };
457 self.bounds = Rect::new(0.0, 0.0, s.width, s.height);
458 s
459 }
460
461 fn paint(&mut self, _: &mut dyn DrawCtx) {}
462
463 fn paint_overlay(&mut self, ctx: &mut dyn DrawCtx) {
464 // Interactive tips paint their surface in `paint_global_overlay`,
465 // not through the lightweight text queue.
466 if self.interactive {
467 return;
468 }
469
470 // Advance the delay/reshow/autopop state machine. It handles its own
471 // invalidation and schedules the wall-clock wake for the delayed show
472 // or auto-dismiss, so no busy-repaint loop is needed.
473 if !self.update_visibility() {
474 return;
475 }
476
477 let anchor = if self.at_pointer {
478 current_mouse_world().unwrap_or(self.cursor)
479 } else {
480 let mut x = self.bounds.width * 0.5;
481 // Widget-anchored tooltips should appear below the
482 // hovered widget by default (MatterCAD-style). In
483 // agg-gui's Y-up coords, the bottom edge is y=0; the
484 // global paint step will offset the panel by
485 // `TOOLTIP_GAP` from this anchor.
486 let mut y = 0.0;
487 ctx.root_transform().transform(&mut x, &mut y);
488 Point::new(x, y)
489 };
490 submit_tooltip(TooltipRequest {
491 font: Arc::clone(&self.font),
492 lines: self.active_lines(),
493 anchor,
494 at_pointer: self.at_pointer,
495 });
496 }
497
498 fn paint_global_overlay(&mut self, ctx: &mut dyn DrawCtx) {
499 if self.interactive {
500 self.paint_interactive_tip(ctx);
501 }
502 }
503
504 fn hit_test_global_overlay(&self, local_pos: Point) -> bool {
505 self.interactive && self.interactive_hit(local_pos)
506 }
507
508 fn on_unconsumed_key(
509 &mut self,
510 key: &crate::event::Key,
511 _modifiers: crate::event::Modifiers,
512 ) -> EventResult {
513 if self.interactive {
514 self.interactive_unconsumed_key(key)
515 } else {
516 EventResult::Ignored
517 }
518 }
519
520 fn on_event(&mut self, event: &Event) -> EventResult {
521 if self.interactive {
522 return self.on_interactive_event(event);
523 }
524 match event {
525 Event::MouseMove { pos } => {
526 let was = self.hovered;
527 self.hovered = self.hit_test(*pos);
528 self.cursor = *pos;
529 if self.hovered && !was {
530 // Entering: start the hover timer, picking the reshow delay
531 // when the subsystem is still warm from a recent tip.
532 self.hover_started_at = Some(tooltip_now());
533 self.pending_delay = self.effective_delay();
534 self.suppressed = false;
535 crate::animation::request_draw_after_tagged(
536 self.pending_delay,
537 "tooltip.wrapper.enter_arm",
538 );
539 } else if !self.hovered && was {
540 // Leaving: reset so the next entry re-arms cleanly and any
541 // press/autopop suppression clears.
542 self.hover_started_at = None;
543 self.suppressed = false;
544 self.hide_tip();
545 }
546 if self.hovered != was {
547 crate::animation::request_draw();
548 }
549 self.children
550 .first_mut()
551 .map(|child| child.on_event(event))
552 .unwrap_or(EventResult::Ignored)
553 }
554 Event::MouseDown { .. } => {
555 // A press over the control hides the tip and keeps it hidden
556 // until the pointer leaves and re-enters.
557 self.pointer_down = true;
558 self.suppressed = true;
559 self.hide_tip();
560 self.children
561 .first_mut()
562 .map(|child| child.on_event(event))
563 .unwrap_or(EventResult::Ignored)
564 }
565 Event::MouseUp { .. } => {
566 self.pointer_down = false;
567 self.children
568 .first_mut()
569 .map(|child| child.on_event(event))
570 .unwrap_or(EventResult::Ignored)
571 }
572 Event::MouseWheel { .. } => {
573 self.hovered = false;
574 self.hover_started_at = None;
575 self.suppressed = false;
576 self.hide_tip();
577 self.children
578 .first_mut()
579 .map(|child| child.on_event(event))
580 .unwrap_or(EventResult::Ignored)
581 }
582 _ => self
583 .children
584 .first_mut()
585 .map(|child| child.on_event(event))
586 .unwrap_or(EventResult::Ignored),
587 }
588 }
589
590 fn hit_test(&self, local_pos: Point) -> bool {
591 local_pos.x >= 0.0
592 && local_pos.x <= self.bounds.width
593 && local_pos.y >= 0.0
594 && local_pos.y <= self.bounds.height
595 }
596}
597
598fn text_to_lines(text: impl Into<String>) -> Vec<TooltipLine> {
599 text.into()
600 .lines()
601 .map(|line| TooltipLine {
602 text: line.to_owned(),
603 kind: TooltipLineKind::Text,
604 })
605 .collect()
606}
607
608#[cfg(test)]
609mod tests;